<?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: DEUS Automations</title>
    <description>The latest articles on DEV Community by DEUS Automations (@deusautomations).</description>
    <link>https://dev.to/deusautomations</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4100149%2Fb32ff895-0ef9-4d13-ad64-94ce62e1050f.png</url>
      <title>DEV Community: DEUS Automations</title>
      <link>https://dev.to/deusautomations</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deusautomations"/>
    <language>en</language>
    <item>
      <title>HTTP 402 is not reserved for future use anymore: I put a 1-cent paywall on a live API for AI agents</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sun, 20 Sep 2026 20:25:42 +0000</pubDate>
      <link>https://dev.to/deusautomations/http-402-is-not-reserved-for-future-use-anymore-i-put-a-1-cent-paywall-on-a-live-api-for-ai-agents-4n6h</link>
      <guid>https://dev.to/deusautomations/http-402-is-not-reserved-for-future-use-anymore-i-put-a-1-cent-paywall-on-a-live-api-for-ai-agents-4n6h</guid>
      <description>&lt;p&gt;HTTP 402 has been "reserved for future use" since 1997. The future turned out to be AI agents that hold a wallet, and the spec that woke it up is &lt;a href="https://x402.org" rel="noopener noreferrer"&gt;x402&lt;/a&gt;: a payment challenge carried in the response body, settled in stablecoin, with no account, no API key and no signup on either side.&lt;/p&gt;

&lt;p&gt;I shipped it on a real endpoint. You can hit it right now — the challenge is public, the paid call costs one cent, and the data behind it is a free CC BY 4.0 dataset. Everything below is a copy-pasteable curl against a live service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://piratefly.com/v1/x402/analisi?origine=Stockholm&amp;amp;destinazione=Rome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get an &lt;code&gt;HTTP 402 Payment Required&lt;/code&gt; with a machine-readable body:&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;"x402Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"payment required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accepts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="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;"scheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"network"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxAmountRequired"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://piratefly.com/v1/x402/analisi?origine=Stockholm&amp;amp;destinazione=Rome"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Booking-timing verdict for one flight route: cheap/normal/expensive vs that route's own price history, best months to fly, how far ahead to book."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mimeType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"payTo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0xAC13DFB06F5c2Db996C881367E19387473Cf6480"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxTimeoutSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"asset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;maxAmountRequired: "10000"&lt;/code&gt; is 10,000 units of USDC on Base — six decimals, so &lt;strong&gt;$0.01&lt;/strong&gt;. No key to request, no plan to pick, no email. An agent reads &lt;code&gt;accepts&lt;/code&gt;, signs an EIP-712 authorization for exactly that amount, retries with an &lt;code&gt;X-PAYMENT&lt;/code&gt; header, and gets the JSON.&lt;/p&gt;

&lt;p&gt;That is the whole protocol. It fits in a paragraph because it has to: the client is a program that has never seen your docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 402 and not an API key
&lt;/h2&gt;

&lt;p&gt;Machine buyers break the assumptions API keys are built on.&lt;/p&gt;

&lt;p&gt;An API key is a &lt;strong&gt;relationship&lt;/strong&gt;: someone signs up, agrees to terms, puts a card on file, and the key is the durable token of that relationship. That model assumes the buyer will come back, and that the cost of onboarding is amortized over many calls.&lt;/p&gt;

&lt;p&gt;An agent buying one answer, once, has none of that. It found your endpoint 40 milliseconds ago, it wants one route analysed, and it will likely never call you again. Onboarding costs more than the sale. Under an API-key model the transaction simply does not happen — not because the buyer won't pay, but because there is no way to pay that fits inside one request.&lt;/p&gt;

&lt;p&gt;402 collapses the relationship into the request itself. The price is in the response. The authorization is in the retry. There is no account to create, so there is nothing to abandon.&lt;/p&gt;

&lt;p&gt;The other half matters just as much for a small operator: &lt;strong&gt;no chargebacks and no merchant identity&lt;/strong&gt;. Settlement is a stablecoin transfer on Base, verified by a facilitator, final when it lands. You do not need a company, a KYC file, or a payment processor's approval to accept a cent. For anyone shipping from outside the usual jurisdictions, that difference is the difference between having a business model and not having one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing it: the part that actually bites
&lt;/h2&gt;

&lt;p&gt;The protocol is small. Most of the implementation is about 40 lines of Hono. Here is the shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RESOURCE_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v1/x402/analisi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&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;RESOURCE_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;origine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;destinazione&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;destinazione&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;origine&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;destinazione&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;errore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required params: origine, destinazione&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resourceUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;RESOURCE_PATH&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?origine=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origine&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;amp;destinazione=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destinazione&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentRequirements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requisiti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wallet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resourceUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x402Version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;paymentRequirements&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&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="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;facilitator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentRequirements&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x402Version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invalidReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;paymentRequirements&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;analizza&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;destinazione&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the actual work&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;facilitator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentRequirements&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x402Version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;paymentRequirements&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there cost me real debugging time, and neither is in the spec's happy path.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The resource URL must be the URL the buyer actually typed
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;paymentRequirements.resource&lt;/code&gt; is signed over. A strict client compares it against the URL it requested, and rejects the challenge if they differ. That sounds trivial until your service runs behind a reverse proxy.&lt;/p&gt;

&lt;p&gt;My API sits behind Traefik with a path-strip rule. Inside the container, &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Host&lt;/code&gt; describe the &lt;em&gt;internal&lt;/em&gt; origin, not the public one buyers reach. Building &lt;code&gt;resource&lt;/code&gt; from request headers produced a URL that was correct from the server's point of view and wrong from every buyer's — and it failed silently, as a payment the client declined to attempt.&lt;/p&gt;

&lt;p&gt;So the public base URL is passed in explicitly at mount time, never inferred:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;montaX402&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sorgente&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://piratefly.com&lt;/span&gt;&lt;span class="dl"&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 take one thing from this post: &lt;strong&gt;do not derive a signed value from a header a proxy is allowed to rewrite.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Verify before you work, settle after
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;verify&lt;/code&gt; is cheap and tells you the authorization is valid. &lt;code&gt;settle&lt;/code&gt; is the on-chain move and can fail for reasons that have nothing to do with the buyer's intent. Put the work between them and you get the ordering you want: you never compute for a buyer who can't pay, and you never charge a buyer whose answer you failed to produce. If &lt;code&gt;settle&lt;/code&gt; fails after the work is done, you eat the compute — which, for a database read, is the right side to lose on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the endpoint actually sells
&lt;/h2&gt;

&lt;p&gt;A price is only honest if there's something behind it. The paid call returns a booking-timing verdict for one route, computed against that route's own observed price history: is today's fare cheap, normal or expensive; which months are actually cheaper; how far ahead to book.&lt;/p&gt;

&lt;p&gt;The history is real and public. As of this writing:&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;price observations&lt;/td&gt;
&lt;td&gt;728,194&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;routes tracked&lt;/td&gt;
&lt;td&gt;48,731&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;routes published (≥5 observations)&lt;/td&gt;
&lt;td&gt;24,283&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;route-months published&lt;/td&gt;
&lt;td&gt;37,688&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;observation window&lt;/td&gt;
&lt;td&gt;2026-06-14 → 2026-09-20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;median fare&lt;/td&gt;
&lt;td&gt;€114&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It is mirrored daily to two places, CC BY 4.0, no account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hugging Face&lt;/strong&gt; — &lt;a href="https://huggingface.co/datasets/DeusHorizon/flight-fare-history" rel="noopener noreferrer"&gt;&lt;code&gt;DeusHorizon/flight-fare-history&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; — &lt;a href="https://github.com/DeusAcc/flight-fare-history" rel="noopener noreferrer"&gt;&lt;code&gt;DeusAcc/flight-fare-history&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://huggingface.co/datasets/DeusHorizon/flight-fare-history/resolve/main/routes.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nlargest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;observations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin_city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination_city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;median_eur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_eur&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;Publishing the data the paid endpoint reads from sounds like giving away the product. It isn't. The CSV is the &lt;em&gt;history&lt;/em&gt;; the endpoint is the &lt;em&gt;verdict&lt;/em&gt; — current fare percentile against that history, computed on a daily-refreshed table you'd otherwise have to rebuild yourself. Anyone willing to do the work can do it from the free file. Charging a cent is the price of not doing the work.&lt;/p&gt;

&lt;p&gt;Free JSON is also available without any payment at &lt;code&gt;GET /v1/analisi&lt;/code&gt; (same shape, OpenAPI at &lt;a href="https://piratefly.com/v1/openapi.json?src=devto" rel="noopener noreferrer"&gt;&lt;code&gt;/v1/openapi.json&lt;/code&gt;&lt;/a&gt;), and there's a remote MCP server at &lt;code&gt;https://piratefly.com/mcp&lt;/code&gt; if you'd rather let a model call it as a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is anyone actually paying?
&lt;/h2&gt;

&lt;p&gt;Honest answer: the endpoint is live, registered in the public x402 resource directories, and has not yet earned a cent. I'm writing this at the stage where the mechanism works and the demand is unproven — which is, I think, the only interesting time to write about it, before the survivorship bias sets in.&lt;/p&gt;

&lt;p&gt;What I can say with confidence is that the failure mode changed. Before, the reason an agent couldn't buy from me was structural: no signup flow a program can complete, no way to price one call. Now the reason would be that nobody wants the answer. That is a much better problem, and a measurable one.&lt;/p&gt;

&lt;p&gt;If you're building something an agent might want to buy — a lookup, a verdict, a computation over data you maintain — the implementation cost is genuinely about an afternoon. The spec is at &lt;a href="https://x402.org" rel="noopener noreferrer"&gt;x402.org&lt;/a&gt;, the facilitator does the chain work, and your endpoint changes by one branch at the top.&lt;/p&gt;

&lt;p&gt;Hit the 402 yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://piratefly.com/v1/x402/analisi?origine=Stockholm&amp;amp;destinazione=Rome"&lt;/span&gt; | jq .accepts[0]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service this runs on is &lt;a href="https://piratefly.com/?src=devto" rel="noopener noreferrer"&gt;PirateFly&lt;/a&gt; — flight fare history, free API, and the 402 endpoint above.&lt;/p&gt;

&lt;p&gt;Happy to answer implementation questions in the comments — especially from anyone who has taken a 402 endpoint further than I have.&lt;/p&gt;

</description>
      <category>api</category>
      <category>ai</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A free flight-price API that tells you buy or wait, not just today's fare</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:06:18 +0000</pubDate>
      <link>https://dev.to/deusautomations/a-free-flight-price-api-that-tells-you-buy-or-wait-not-just-todays-fare-17pd</link>
      <guid>https://dev.to/deusautomations/a-free-flight-price-api-that-tells-you-buy-or-wait-not-just-todays-fare-17pd</guid>
      <description>&lt;p&gt;Every flight comparison site shows you today's cheapest fare. None of them will tell you to wait — it costs them the booking commission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Piratefly&lt;/strong&gt; watches flight prices over time and answers the actual question: is this price good, or should you wait? We just opened that logic up as a free JSON API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://next.piratefly.com/v1/analisi?origine=FCO&amp;amp;destinazione=BCN
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give it an IATA origin and destination, get back today's cheapest fare, where it sits against that route's own price history (percentile), and a buy/wait verdict.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://next.piratefly.com/v1/analisi?origine=FCO&amp;amp;destinazione=BCN"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Plans
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt; — 30 requests/day per IP, no signup, no key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pro — $9.99/month&lt;/strong&gt; — no daily cap. &lt;code&gt;POST /v1/pro/checkout&lt;/code&gt; with your email, pay on Stripe, get an &lt;code&gt;X-Api-Key&lt;/code&gt; back immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full machine-readable reference: &lt;a href="https://next.piratefly.com/v1/docs" rel="noopener noreferrer"&gt;https://next.piratefly.com/v1/docs&lt;/a&gt;&lt;br&gt;
Landing page with the Pro checkout: &lt;a href="https://next.piratefly.com/api" rel="noopener noreferrer"&gt;https://next.piratefly.com/api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We only publish a verdict once a route has at least 8 price observations behind it — below that we say nothing rather than guess. Feedback and route requests welcome.&lt;/p&gt;

</description>
      <category>api</category>
      <category>travel</category>
      <category>json</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your open-source repo's issue queue doesn't have to sit there for months</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Wed, 09 Sep 2026 04:44:30 +0000</pubDate>
      <link>https://dev.to/deusautomations/your-open-source-repos-issue-queue-doesnt-have-to-sit-there-for-months-7d0</link>
      <guid>https://dev.to/deusautomations/your-open-source-repos-issue-queue-doesnt-have-to-sit-there-for-months-7d0</guid>
      <description>&lt;p&gt;Small and medium open-source repos accumulate bug reports and feature requests that sit open for months because nobody has time to triage them. Every maintainer knows the feeling of opening the Issues tab and seeing 40+ items with zero labels, zero triage, zero idea which ones are quick fixes and which ones are rabbit holes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Issue Triage BYOK&lt;/strong&gt; is a small WordPress plugin that gives you a wp-admin dashboard listing a repository's open GitHub issues, with a one-click "Generate fix proposal" button. Click it and an AI model reads the issue title and body and gives you back a likely root cause, a concrete fix, and a suggested test — you copy, adapt, and act on it yourself.&lt;/p&gt;

&lt;p&gt;A few things it deliberately does NOT do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It never writes to GitHub, comments, labels, or opens pull requests. Read-only, always.&lt;/li&gt;
&lt;li&gt;It doesn't ship an API key of its own. BYOK (Bring Your Own Key): you plug in your own GitHub token and your own AI key (any OpenAI-compatible chat completions endpoint works, not just OpenAI).&lt;/li&gt;
&lt;li&gt;No subscription for the base functionality: the free version generates up to 20 AI fix proposals per month, which is enough to stay on top of a small/medium repo's queue. A one-time-payment Pro license removes the monthly cap if you need more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why WordPress and not a GitHub Action or a CLI tool? Because a lot of maintainers of small plugins/themes/tools already live in wp-admin for their own site, and having the triage dashboard one click away (rather than another terminal tab or CI log to check) is the difference between actually doing triage weekly and letting it pile up.&lt;/p&gt;

&lt;p&gt;If you maintain a repo where issues outnumber your available time, it's on the WordPress plugin directory search and here: &lt;a href="https://shop.lumnika.com/gh-issue-triage-byok.html" rel="noopener noreferrer"&gt;https://shop.lumnika.com/gh-issue-triage-byok.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the BYOK approach or how the fix-proposal prompt is built.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>github</category>
      <category>wordpress</category>
      <category>ai</category>
    </item>
    <item>
      <title>We Scanned Random Websites for Missing Alt Text. Even Wikipedia Failed.</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 05 Sep 2026 02:47:50 +0000</pubDate>
      <link>https://dev.to/deusautomations/we-scanned-random-websites-for-missing-alt-text-even-wikipedia-failed-3fn8</link>
      <guid>https://dev.to/deusautomations/we-scanned-random-websites-for-missing-alt-text-even-wikipedia-failed-3fn8</guid>
      <description>&lt;p&gt;Alt text isn't optional. Under WCAG 2.1 success criterion 1.1.1 ("Non-text Content"), every&lt;br&gt;
meaningful image needs a text alternative — and it's one of the most commonly cited failures in&lt;br&gt;
automated accessibility audits (WebAIM's Million report has flagged missing alt text as the&lt;br&gt;
single most frequent error on the web for years running).&lt;/p&gt;

&lt;p&gt;Finding every offending image by hand is the boring part, so we built a small crawler that walks&lt;br&gt;
a site's public pages and flags every &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; with a missing or empty &lt;code&gt;alt&lt;/code&gt; attribute. To sanity&lt;br&gt;
check it, we pointed it at a handful of real, well-known domains instead of a synthetic test&lt;br&gt;
page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;One of the sites we scanned was &lt;code&gt;wikipedia.org&lt;/code&gt;. A single-page scan of the portal landing page —&lt;br&gt;
the one that lets you pick a language — turned up an image with &lt;strong&gt;no alt attribute at all&lt;/strong&gt;: the&lt;br&gt;
Wikipedia wordmark logo itself (&lt;code&gt;Wikipedia-logo-v2.png&lt;/code&gt;). One of the most-visited, most-audited&lt;br&gt;
sites on the internet, and its own front door ships a logo image a screen reader has no way to&lt;br&gt;
describe.&lt;/p&gt;

&lt;p&gt;That's not a dig at Wikipedia specifically — it's the point. If a site with that much scrutiny can&lt;br&gt;
ship an unlabeled image on its landing page, it happens everywhere, silently, on every site that&lt;br&gt;
doesn't check for it systematically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;A few patterns we see repeatedly when auditing real sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logos and icons get skipped.&lt;/strong&gt; Teams treat "obviously branding" images as exempt from
needing alt text, but a screen reader user still needs to know what's there (or that it's
purely decorative and should have &lt;code&gt;alt=""&lt;/code&gt;, not a missing attribute).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alt text is added once, at launch, and never re-checked.&lt;/strong&gt; New pages, new CMS templates, new
marketing images ship without it because nothing in the pipeline catches the omission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Missing" and "empty" get conflated.&lt;/strong&gt; A missing &lt;code&gt;alt&lt;/code&gt; attribute is a hard WCAG failure. An
empty &lt;code&gt;alt=""&lt;/code&gt; on a genuinely decorative image is &lt;em&gt;correct&lt;/em&gt;. Telling the two apart at scale is
exactly the kind of repetitive checking that's easy to skip by hand and easy to automate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checking your own site
&lt;/h2&gt;

&lt;p&gt;If you want to see this on your own domain rather than take our word for it, we put a free,&lt;br&gt;
no-signup version of the same scanner online: it crawls up to 10 public pages, lists every image&lt;br&gt;
with missing or empty alt text, and drafts a ready-to-paste alt text for each one using a vision&lt;br&gt;
model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shop.lumnika.com/alt-text-audit.html?src=devto" rel="noopener noreferrer"&gt;Try the free scanner →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No login, no email required for the sample scan — paste a domain and see what it finds. The paid&lt;br&gt;
version (CSV + report, up to 300 images) is there if you want the full site covered end to end,&lt;br&gt;
but the free scan alone is enough to tell you whether it's worth doing.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Watching a Solana wallet without running your own RPC node</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:23:51 +0000</pubDate>
      <link>https://dev.to/deusautomations/watching-a-solana-wallet-without-running-your-own-rpc-node-2lnn</link>
      <guid>https://dev.to/deusautomations/watching-a-solana-wallet-without-running-your-own-rpc-node-2lnn</guid>
      <description>&lt;h2&gt;
  
  
  Watching a Solana wallet without running your own RPC node
&lt;/h2&gt;

&lt;p&gt;If you want to know the moment a specific Solana wallet's balance moves — a treasury, a whale you're tracking, your own hot wallet for a security check — the "proper" way people reach for is running a dedicated indexer or paying for a webhook service like Helius. For a single wallet with modest polling needs, that's overkill.&lt;/p&gt;

&lt;p&gt;Solana's public RPC (&lt;code&gt;api.mainnet-beta.solana.com&lt;/code&gt;) is rate-limited but free, and it's enough to poll a wallet's SOL and SPL token balances on a schedule without an API key at all. Here's the pattern I used, in case you're about to build a similar watcher and don't want to pay for infra you don't need yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The loop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Poll every 5 minutes&lt;/strong&gt; — &lt;code&gt;getBalance&lt;/code&gt; for SOL, &lt;code&gt;getTokenAccountsByOwner&lt;/code&gt; for SPL tokens, against the public RPC endpoint. No auth, no key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff against last known balance&lt;/strong&gt; — stored between runs (a simple key-value store is enough; I used n8n's built-in static data, but a Sheet or a Redis key works the same).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold check&lt;/strong&gt; — only fire an alert if the change exceeds a threshold you set. Without this, you get pinged on every dust transaction and start ignoring the channel within a day — the threshold is what makes the alert actually useful instead of noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert with context&lt;/strong&gt; — a Telegram message with the delta and a link straight to the transaction on Solscan, so you don't have to go look it up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Where the public RPC runs out
&lt;/h3&gt;

&lt;p&gt;At low polling frequency (minutes, not seconds) and a handful of wallets, the public endpoint holds up fine. If you're watching dozens of wallets or need sub-minute latency, you'll hit rate limits and want to swap in a dedicated RPC (Helius, QuickNode, Triton) — the workflow is built so that's a one-field change, not a rewrite, since the RPC URL is a single credential.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you want the built version
&lt;/h3&gt;

&lt;p&gt;Packaged as an n8n workflow — polling, diffing, thresholding, and the Telegram alert with the Solscan link already wired up:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shop.lumnika.com/solana-wallet-watcher.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solana-wallet-watcher" rel="noopener noreferrer"&gt;https://shop.lumnika.com/solana-wallet-watcher.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solana-wallet-watcher&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$14.99, one-time, no API key required to get started since it uses the public RPC by default.&lt;/p&gt;

&lt;p&gt;Anyone else tracking wallets for security/ops reasons rather than trading? Curious what thresholds people settled on before the alerts stopped being noise.&lt;/p&gt;

</description>
      <category>solana</category>
      <category>n8n</category>
      <category>automation</category>
      <category>crypto</category>
    </item>
    <item>
      <title>Syncing a CRM with Odoo without paying for an iPaaS subscription</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:23:24 +0000</pubDate>
      <link>https://dev.to/deusautomations/syncing-a-crm-with-odoo-without-paying-for-an-ipaas-subscription-35n3</link>
      <guid>https://dev.to/deusautomations/syncing-a-crm-with-odoo-without-paying-for-an-ipaas-subscription-35n3</guid>
      <description>&lt;h2&gt;
  
  
  Syncing a CRM with Odoo without paying for an iPaaS subscription
&lt;/h2&gt;

&lt;p&gt;Odoo is great as an ERP, but the moment sales wants to keep using HubSpot or Pipedrive as their CRM, you're stuck with a sync problem: contacts and orders need to exist correctly in both places, in both directions, without double-creating records or silently dropping updates.&lt;/p&gt;

&lt;p&gt;The "just use Zapier/Make/Workato" answer works, but at CRM+ERP volume you're often paying $50-300/mo for what is, underneath, a scheduled poll and a webhook. I ended up building this as a plain n8n workflow instead, and wanted to share the pattern since it generalizes to basically any "sync system A with system B" problem, not just Odoo.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two directions are not symmetric, and that matters
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Odoo → CRM&lt;/strong&gt;: a scheduled poll every 15 minutes, using Odoo's &lt;code&gt;write_date&lt;/code&gt; field to pull only records changed since the last run (a delta sync, not a full table scan — this is the difference between a sync that stays fast at 50 records and one that stays fast at 50,000). Each changed &lt;code&gt;res.partner&lt;/code&gt; or &lt;code&gt;sale.order&lt;/code&gt; gets upserted into the CRM via its REST API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRM → Odoo&lt;/strong&gt;: a webhook, because CRMs push changes in real time and there's no reason to wait 15 minutes for a lead update to land in Odoo. The webhook branches on whether the incoming record is new (create) or existing (update), matched by an external-ID field kept on both sides.&lt;/p&gt;

&lt;p&gt;The asymmetry is the part people usually get wrong when they build this the first time: they either poll both directions (adds needless latency on the CRM side) or webhook both directions (Odoo doesn't push webhooks natively without extra modules). Poll the system that doesn't push, webhook the one that does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Matching records without collisions
&lt;/h3&gt;

&lt;p&gt;Both &lt;code&gt;res.partner&lt;/code&gt; (contacts) and &lt;code&gt;sale.order&lt;/code&gt; (orders) get an external-ID field synced on both sides at creation time, so updates always match the right record instead of relying on fuzzy matching (name + email, which breaks the moment someone has a typo or a duplicate).&lt;/p&gt;

&lt;h3&gt;
  
  
  If you want the built version
&lt;/h3&gt;

&lt;p&gt;I packaged this as a 27-node n8n workflow — Odoo delta poll, CRM webhook with create/update branching, external-ID matching for both contacts and orders:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shop.lumnika.com/crm-odoo-sync.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=crm-odoo-sync" rel="noopener noreferrer"&gt;https://shop.lumnika.com/crm-odoo-sync.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=crm-odoo-sync&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$49, one-time, works with HubSpot, Pipedrive, or any CRM with a REST webhook — you import it into your own n8n instance and point it at your credentials.&lt;/p&gt;

&lt;p&gt;If you've built a two-way sync between other systems, I'd like to hear what matching strategy you used instead of external IDs — curious if anyone's had a cleaner approach.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turning a folder of PDFs into a webhook that returns JSON</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Thu, 03 Sep 2026 00:23:21 +0000</pubDate>
      <link>https://dev.to/deusautomations/turning-a-folder-of-pdfs-into-a-webhook-that-returns-json-40pg</link>
      <guid>https://dev.to/deusautomations/turning-a-folder-of-pdfs-into-a-webhook-that-returns-json-40pg</guid>
      <description>&lt;h2&gt;
  
  
  Turning a folder of PDFs into a webhook that returns JSON (no OCR infra to run)
&lt;/h2&gt;

&lt;p&gt;If you've ever had to pull data out of PDFs at scale — invoices, ID scans, scanned forms, shipping manifests — you know the annoying part isn't the OCR itself, it's everything around it: standing up a service, handling the queueing, storing the output somewhere useful, and doing it all without paying for a $200/mo document-AI SaaS for what's often a low-volume internal tool.&lt;/p&gt;

&lt;p&gt;I ended up solving this with a single n8n workflow instead of a custom service, and it turned out simpler than I expected, so here's the shape of it in case you're about to build the same thing from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pipeline
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhook in&lt;/strong&gt; — POST a PDF URL (or upload) to a single endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR&lt;/strong&gt; — the file goes to &lt;a href="https://ocr.space" rel="noopener noreferrer"&gt;OCR.space&lt;/a&gt;, which has a genuinely usable free tier (25k requests/month, no card required). No self-hosted Tesseract, no GPU, no Docker image to maintain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text → structure&lt;/strong&gt; — the raw OCR text gets parsed into a best-effort table: rows, columns, a JSON array, and a CSV export, using a mix of layout heuristics on the OCR bounding boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log (optional)&lt;/strong&gt; — every extraction gets appended to a Google Sheet automatically, so you get a free audit trail without adding a database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response&lt;/strong&gt; — the webhook returns plain text + structured JSON + CSV, so any app, script, or Zapier/Make alternative can call it directly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part worth calling out: because OCR.space is a hosted API, the whole thing runs with zero infrastructure of your own — n8n is the only thing you need to keep running, and if you already use n8n for other automations, this is just one more workflow, not a new service to babysit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this breaks down
&lt;/h3&gt;

&lt;p&gt;Layout-heavy PDFs (multi-column, dense tables with merged cells) will always need some manual review — no OCR pipeline gets that free. This is aimed at the 80% case: invoices, forms, receipts, single-column reports, where "close enough JSON" saves someone an afternoon of manual data entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you'd rather not build it
&lt;/h3&gt;

&lt;p&gt;I packaged the exact workflow — webhook, OCR.space call, structuring logic, optional Sheets logging — as a ready-to-import n8n template:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shop.lumnika.com/pdf-ocr-extractor.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pdf-ocr" rel="noopener noreferrer"&gt;https://shop.lumnika.com/pdf-ocr-extractor.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pdf-ocr&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$19.99, one-time, JSON file you import into your own n8n instance — no subscription, no vendor lock-in, you can edit every node.&lt;/p&gt;

&lt;p&gt;Curious if anyone here has fought with layout-heavy PDF extraction and found a heuristic that actually holds up — table detection is still the weakest link in every pipeline I've tried.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>ocr</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is Your Shopify or WooCommerce Store an ADA/WCAG Lawsuit Risk? Free 2-Minute Check</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:34:02 +0000</pubDate>
      <link>https://dev.to/deusautomations/is-your-shopify-or-woocommerce-store-an-adawcag-lawsuit-risk-free-2-minute-check-2h3b</link>
      <guid>https://dev.to/deusautomations/is-your-shopify-or-woocommerce-store-an-adawcag-lawsuit-risk-free-2-minute-check-2h3b</guid>
      <description>&lt;p&gt;Every year, ADA (Title III) web-accessibility lawsuits against US retailers keep climbing, and a huge share of them cite the same root cause: product images with no &lt;code&gt;alt&lt;/code&gt; text. If you run a Shopify or WooCommerce store, that's usually not a design choice — it's just what happens when a catalog grows to hundreds or thousands of SKUs and nobody goes back to fill in &lt;code&gt;alt&lt;/code&gt; attributes.&lt;/p&gt;

&lt;p&gt;I wanted to know how exposed a "typical" store actually is, so I built a free scanner and ran it against a handful of real storefronts. Here's what I found, and the tool so you can check your own store in about two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why missing alt text is a legal risk, not just an SEO nitpick
&lt;/h2&gt;

&lt;p&gt;Screen readers rely on &lt;code&gt;alt&lt;/code&gt; text to describe images to blind and low-vision shoppers. When a product image has no &lt;code&gt;alt&lt;/code&gt;, or a generic one like &lt;code&gt;IMG_4021.jpg&lt;/code&gt;, a screen reader either skips it or reads the filename — the shopper has no idea what they're looking at, can't tell products apart, and often can't complete checkout.&lt;/p&gt;

&lt;p&gt;That's the exact pattern plaintiffs' firms look for before sending a demand letter: it's easy to prove (view source), easy to explain to a judge, and expensive to defend even when the claim doesn't go to trial. Settlements for small-to-mid retailers commonly land in the $5,000–$20,000 range — for something that's a content-population problem, not a code bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scan actually checks
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://shop.lumnika.com/compliancesnap/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ada-alt-text" rel="noopener noreferrer"&gt;ComplianceSnap&lt;/a&gt; to answer one narrow question fast: &lt;strong&gt;out of all the product images on your store, how many have no usable alt text?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You paste a store URL, it crawls the product pages, and it reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;total product images found&lt;/li&gt;
&lt;li&gt;how many are missing &lt;code&gt;alt&lt;/code&gt; (or have placeholder/filename-only alt)&lt;/li&gt;
&lt;li&gt;an estimated risk band based on catalog size and % missing (a 2,000-SKU store at 80% missing is a very different risk profile than a 40-SKU store at 10%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's read-only — it doesn't touch your store, theme, or admin, it just reads the public pages the same way a screen reader or a plaintiff's audit tool would.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found scanning real stores
&lt;/h2&gt;

&lt;p&gt;Running it against a small sample of public Shopify/WooCommerce stores, the missing-alt rate ranged from under 10% (stores that clearly have an accessibility process) to over 70% (stores where alt text was never part of the product-upload workflow at all). The pattern held regardless of store size — it's a process gap, not a platform limitation. Both Shopify and WooCommerce support alt text natively; almost nobody fills it in by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  If your scan comes back bad
&lt;/h2&gt;

&lt;p&gt;The scan itself is free and doesn't require an account. If you want the missing alt text actually written (AI-generated, per image, reviewed before you publish), that's a paid step — the free scan is just there so you know whether it's worth doing before you spend anything.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;strong&gt;&lt;a href="https://shop.lumnika.com/compliancesnap/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ada-alt-text" rel="noopener noreferrer"&gt;https://shop.lumnika.com/compliancesnap/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ada-alt-text&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Curious what other devs/store owners have seen on this — did anyone here actually get an ADA demand letter over image alt text, or is it mostly theoretical risk?&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Most "AI Resume Builders" Produce PDFs That Fail ATS Parsers</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 15:56:15 +0000</pubDate>
      <link>https://dev.to/deusautomations/why-most-ai-resume-builders-produce-pdfs-that-fail-ats-parsers-153l</link>
      <guid>https://dev.to/deusautomations/why-most-ai-resume-builders-produce-pdfs-that-fail-ats-parsers-153l</guid>
      <description>&lt;p&gt;If you've ever run your own resume through an ATS (Applicant Tracking System) parser and watched&lt;br&gt;
your job title or bullet points come out garbled, you've hit a problem that has nothing to do with&lt;br&gt;
your content and everything to do with how the PDF was built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: PDF is a layout format, not a text format
&lt;/h2&gt;

&lt;p&gt;A PDF doesn't store "a paragraph followed by another paragraph." It stores absolute-positioned&lt;br&gt;
glyphs on a page. Most polished-looking resume templates get that layout from HTML+CSS rendered&lt;br&gt;
through a headless browser (Puppeteer/Chromium), which is great for visual fidelity and terrible&lt;br&gt;
for text order: multi-column layouts, sidebars, and floated elements often get flattened in&lt;br&gt;
reading order that has nothing to do with visual order. An ATS parser reads the underlying text&lt;br&gt;
stream top-to-bottom, left-to-right in &lt;em&gt;insertion&lt;/em&gt; order — so a two-column template can silently&lt;br&gt;
interleave your work history with your skills list, or drop your most recent job title into the&lt;br&gt;
middle of a bullet point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixes it
&lt;/h2&gt;

&lt;p&gt;Two things matter more than template beauty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build the PDF with a low-level drawing API, not a browser.&lt;/strong&gt; Libraries like &lt;code&gt;pdfkit&lt;/code&gt; let you
place text in the exact order you write it in code — single-column templates render sidebar
accents as &lt;em&gt;decoration&lt;/em&gt; (colored rectangles, icons) that never enter the text stream, so the
parser only ever sees the real content, in the real order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always keep a resume-only fallback path.&lt;/strong&gt; If you're generating resume content with an LLM
(turning raw notes into structured bullet points, for example), the model call &lt;em&gt;will&lt;/em&gt;
eventually fail or time out. A parser that falls back to a deterministic, rule-based structuring
of the same raw input — instead of failing the whole request — means the user still gets a
correct, ATS-safe PDF even when the AI provider has a bad day.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A concrete example
&lt;/h2&gt;

&lt;p&gt;I ended up building this into &lt;a href="https://shop.lumnika.com/cv-forge/?src=devto" rel="noopener noreferrer"&gt;CV Forge&lt;/a&gt;, a small AI resume generator: raw notes&lt;br&gt;
go in, an LLM (OpenAI-compatible, tested against Groq's Llama 3.3 70B) turns them into a structured&lt;br&gt;
CV JSON, and &lt;code&gt;pdfkit&lt;/code&gt; renders it with a single-column-first "Compact" template designed to stay&lt;br&gt;
ATS-safe, alongside two more visual templates for when you're sending directly to a human. If the&lt;br&gt;
AI call fails, a local parser produces the same structured JSON so the PDF still comes out&lt;br&gt;
correctly formatted.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past resumes: &lt;strong&gt;any time you're generating a PDF that a machine will read&lt;br&gt;
back&lt;/strong&gt;, decide &lt;em&gt;before&lt;/em&gt; picking a rendering approach whether text-stream order matters as much as&lt;br&gt;
visual layout. If it does, skip the headless browser and reach for a library that lets you control&lt;br&gt;
the text stream directly.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>career</category>
      <category>pdf</category>
    </item>
    <item>
      <title>How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 12:55:27 +0000</pubDate>
      <link>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-2cpc</link>
      <guid>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-2cpc</guid>
      <description>&lt;p&gt;If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty &lt;code&gt;alt&lt;/code&gt; attributes, and a WCAG 2.1 audit (or a client demanding one) breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture.&lt;/p&gt;

&lt;p&gt;This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every &lt;code&gt;attachment&lt;/code&gt; post of MIME type &lt;code&gt;image&lt;/code&gt; should have &lt;code&gt;_wp_attachment_image_alt&lt;/code&gt; set to something meaningful, not "IMG_4821.jpg" and not empty.&lt;/p&gt;

&lt;p&gt;Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's &lt;em&gt;not&lt;/em&gt; trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision: BYOK, not a hosted service
&lt;/h2&gt;

&lt;p&gt;The plugin (&lt;code&gt;Alt Text BYOK&lt;/code&gt;) doesn't call any server of mine. It calls whatever OpenAI-compatible &lt;code&gt;chat/completions&lt;/code&gt; endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose (OpenAI, or any of the growing list of OpenAI-compatible vision endpoints), and nowhere else.&lt;/p&gt;

&lt;p&gt;The settings are deliberately just four fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_default_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'api_base'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://api.openai.com/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'api_key'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'language'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'English'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'overwrite_existing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'license_key'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api_base&lt;/code&gt; is the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same &lt;code&gt;chat/completions&lt;/code&gt; shape with image content parts, and it works. That includes several free-tier vision models if you want to run the whole thing at zero cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual API call
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — a single &lt;code&gt;chat/completions&lt;/code&gt; request with a multimodal &lt;code&gt;content&lt;/code&gt; array (text instruction + &lt;code&gt;image_url&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_call_vision_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_base'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'chat/completions'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$prompt&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Describe this image in %s as a concise, descriptive ALT text for web accessibility. '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Maximum 125 characters. Do not start with "image of" or "picture of". '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Reply with the ALT text only, no quotes, no extra commentary.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'language'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'messages'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'role'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s1"&gt;'type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'image_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$image_url&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="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... error handling, then trim/sanitize the returned text&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two prompt details earned their place after actually looking at model output on real sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Do not start with 'image of' or 'picture of'"&lt;/strong&gt; — screen readers already announce the element as an image; a generated alt text that starts with "Image of a dog running" is redundant and is explicitly called out as an anti-pattern in WCAG guidance. Without this instruction, GPT-4o-mini defaults to that phrasing constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard character cap in the prompt, plus a second cap after the fact&lt;/strong&gt; (&lt;code&gt;mb_substr( $alt, 0, 160 )&lt;/code&gt;) — models don't reliably respect "maximum 125 characters" as an instruction, so the code truncates again server-side rather than trusting the model's arithmetic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding the images that actually need it
&lt;/h2&gt;

&lt;p&gt;The query that drives the bulk-fix screen is a plain &lt;code&gt;WP_Query&lt;/code&gt; against attachments missing the meta key entirely — not empty string, &lt;em&gt;missing&lt;/em&gt;, which is a different &lt;code&gt;meta_query compare&lt;/code&gt; (&lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;=&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_attachments_missing_alt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'attachment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_status'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'inherit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_mime_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'meta_query'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'key'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'_wp_attachment_image_alt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'compare'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'NOT EXISTS'&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&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;&lt;code&gt;post_status =&amp;gt; inherit&lt;/code&gt; is the detail that's easy to get wrong: attachments don't use the normal &lt;code&gt;publish&lt;/code&gt;/&lt;code&gt;draft&lt;/code&gt; statuses, they inherit their parent post's status (or &lt;code&gt;inherit&lt;/code&gt; on their own when unattached). Query for &lt;code&gt;publish&lt;/code&gt; here instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating one at a time, on purpose
&lt;/h2&gt;

&lt;p&gt;The admin UI processes images one AJAX call per image instead of batching them server-side into one big request. That's not an accident — a bulk endpoint that loops over 300 images inside a single PHP request runs straight into &lt;code&gt;max_execution_time&lt;/code&gt; on any shared host, and a failure partway through gives you no idea which images actually got done. One request per image, driven by JS, means progress is visible and a timeout on image #214 doesn't lose the 213 that already succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free-tier limit is deliberate, not a paywall trick
&lt;/h2&gt;

&lt;p&gt;WordPress.org doesn't allow selling anything inside a plugin listed there, so the free version ships with a genuine cap — 30 generations/month, tracked with a month-keyed option that resets itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_usage_get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'atbyok_usage'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$ym&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ym'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ym'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$data&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;Using the current year-month as the array key instead of a scheduled cron job to reset the counter means there's no maintenance task that can silently stop firing — the counter just naturally starts over the first time &lt;code&gt;atbyok_usage_get()&lt;/code&gt; runs in a new month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways if you're building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you're integrating a vision model for a task with a strict output format (short, no boilerplate prefix, character limit), don't trust the prompt alone — enforce the hard constraints in code after the response comes back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meta_query&lt;/code&gt; on attachments: always double check &lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;= ''&lt;/code&gt; vs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads.&lt;/li&gt;
&lt;li&gt;Attachment &lt;code&gt;post_status&lt;/code&gt; is not what you'd guess; query &lt;code&gt;inherit&lt;/code&gt;, not &lt;code&gt;publish&lt;/code&gt;, if you want &lt;em&gt;all&lt;/em&gt; media library items regardless of whether they're attached to a published post.&lt;/li&gt;
&lt;li&gt;For any bulk operation on a shared-hosting WordPress site, prefer many small requests over one big one. &lt;code&gt;max_execution_time&lt;/code&gt; will find you eventually.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I'm the author of &lt;a href="https://shop.lumnika.com/alt-text-byok.html?src=devto" rel="noopener noreferrer"&gt;Alt Text BYOK&lt;/a&gt;, a WordPress plugin that does exactly what's described above — bring your own API key, images never touch a third-party server of mine, free tier included.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>a11y</category>
      <category>php</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to generate WCAG-compliant ALT text for WordPress images without sending them to a vendor's black-box API</title>
      <dc:creator>DEUS Automations</dc:creator>
      <pubDate>Sat, 29 Aug 2026 10:50:32 +0000</pubDate>
      <link>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-3emd</link>
      <guid>https://dev.to/deusautomations/how-to-generate-wcag-compliant-alt-text-for-wordpress-images-without-sending-them-to-a-vendors-3emd</guid>
      <description>&lt;p&gt;If you've ever tried to fix accessibility on an old WordPress site, you know the drill: hundreds of images in the Media Library, most with empty &lt;code&gt;alt&lt;/code&gt; attributes, and a WCAG 2.1 audit (or a client demanding one) breathing down your neck. Writing alt text by hand for 400 images is not a fun Tuesday. Every "AI alt text" SaaS I looked at wanted a monthly subscription, routed my images through their own servers, and gave me zero control over which model actually looked at the picture.&lt;/p&gt;

&lt;p&gt;This post is about the plugin I built to fix that for my own sites, and the handful of implementation details that turned out to matter more than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;WCAG 2.1 Success Criterion 1.1.1 requires non-text content to have a text alternative. In WordPress terms: every &lt;code&gt;attachment&lt;/code&gt; post of MIME type &lt;code&gt;image&lt;/code&gt; should have &lt;code&gt;_wp_attachment_image_alt&lt;/code&gt; set to something meaningful, not "IMG_4821.jpg" and not empty.&lt;/p&gt;

&lt;p&gt;Doing this with a vision-capable LLM is trivial in principle — send the image, ask for a short description, save it as the alt attribute. The part that's &lt;em&gt;not&lt;/em&gt; trivial, if you don't want another recurring SaaS bill and don't want to hand a third party your whole media library, is: whose API key, which model, and where does the image actually go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decision: BYOK, not a hosted service
&lt;/h2&gt;

&lt;p&gt;The plugin (&lt;code&gt;Alt Text BYOK&lt;/code&gt;) doesn't call any server of mine. It calls whatever OpenAI-compatible &lt;code&gt;chat/completions&lt;/code&gt; endpoint you configure, with your own API key. That's the entire trust model: your images go from your WordPress install directly to the provider you already chose (OpenAI, or any of the growing list of OpenAI-compatible vision endpoints), and nowhere else.&lt;/p&gt;

&lt;p&gt;The settings are deliberately just four fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_default_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'api_base'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://api.openai.com/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'api_key'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'language'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'English'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'overwrite_existing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'license_key'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api_base&lt;/code&gt; is the detail that matters most for portability: it's not hardcoded to OpenAI. Point it at any provider that speaks the same &lt;code&gt;chat/completions&lt;/code&gt; shape with image content parts, and it works. That includes several free-tier vision models if you want to run the whole thing at zero cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual API call
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — a single &lt;code&gt;chat/completions&lt;/code&gt; request with a multimodal &lt;code&gt;content&lt;/code&gt; array (text instruction + &lt;code&gt;image_url&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_call_vision_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trailingslashit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_base'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'chat/completions'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$prompt&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Describe this image in %s as a concise, descriptive ALT text for web accessibility. '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Maximum 125 characters. Do not start with "image of" or "picture of". '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'Reply with the ALT text only, no quotes, no extra commentary.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'language'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'model'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'messages'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'role'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$prompt&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="s1"&gt;'type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image_url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'image_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$image_url&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="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'max_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'body'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... error handling, then trim/sanitize the returned text&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two prompt details earned their place after actually looking at model output on real sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Do not start with 'image of' or 'picture of'"&lt;/strong&gt; — screen readers already announce the element as an image; a generated alt text that starts with "Image of a dog running" is redundant and is explicitly called out as an anti-pattern in WCAG guidance. Without this instruction, GPT-4o-mini defaults to that phrasing constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard character cap in the prompt, plus a second cap after the fact&lt;/strong&gt; (&lt;code&gt;mb_substr( $alt, 0, 160 )&lt;/code&gt;) — models don't reliably respect "maximum 125 characters" as an instruction, so the code truncates again server-side rather than trusting the model's arithmetic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding the images that actually need it
&lt;/h2&gt;

&lt;p&gt;The query that drives the bulk-fix screen is a plain &lt;code&gt;WP_Query&lt;/code&gt; against attachments missing the meta key entirely — not empty string, &lt;em&gt;missing&lt;/em&gt;, which is a different &lt;code&gt;meta_query compare&lt;/code&gt; (&lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;=&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_attachments_missing_alt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'attachment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_status'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'inherit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'post_mime_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'meta_query'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'key'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'_wp_attachment_image_alt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'compare'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'NOT EXISTS'&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&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;&lt;code&gt;post_status =&amp;gt; inherit&lt;/code&gt; is the detail that's easy to get wrong: attachments don't use the normal &lt;code&gt;publish&lt;/code&gt;/&lt;code&gt;draft&lt;/code&gt; statuses, they inherit their parent post's status (or &lt;code&gt;inherit&lt;/code&gt; on their own when unattached). Query for &lt;code&gt;publish&lt;/code&gt; here instead and you silently miss every unattached media-library image — which, in practice on most sites, is most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating one at a time, on purpose
&lt;/h2&gt;

&lt;p&gt;The admin UI processes images one AJAX call per image instead of batching them server-side into one big request. That's not an accident — a bulk endpoint that loops over 300 images inside a single PHP request runs straight into &lt;code&gt;max_execution_time&lt;/code&gt; on any shared host, and a failure partway through gives you no idea which images actually got done. One request per image, driven by JS, means progress is visible and a timeout on image #214 doesn't lose the 213 that already succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free-tier limit is deliberate, not a paywall trick
&lt;/h2&gt;

&lt;p&gt;WordPress.org doesn't allow selling anything inside a plugin listed there, so the free version ships with a genuine cap — 30 generations/month, tracked with a month-keyed option that resets itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;atbyok_usage_get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'atbyok_usage'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$ym&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ym'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ym'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$ym&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$data&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;Using the current year-month as the array key instead of a scheduled cron job to reset the counter means there's no maintenance task that can silently stop firing — the counter just naturally starts over the first time &lt;code&gt;atbyok_usage_get()&lt;/code&gt; runs in a new month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways if you're building something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you're integrating a vision model for a task with a strict output format (short, no boilerplate prefix, character limit), don't trust the prompt alone — enforce the hard constraints in code after the response comes back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meta_query&lt;/code&gt; on attachments: always double check &lt;code&gt;NOT EXISTS&lt;/code&gt; vs &lt;code&gt;= ''&lt;/code&gt; vs plain absence — WordPress doesn't guarantee the meta row exists at all for older uploads.&lt;/li&gt;
&lt;li&gt;Attachment &lt;code&gt;post_status&lt;/code&gt; is not what you'd guess; query &lt;code&gt;inherit&lt;/code&gt;, not &lt;code&gt;publish&lt;/code&gt;, if you want &lt;em&gt;all&lt;/em&gt; media library items regardless of whether they're attached to a published post.&lt;/li&gt;
&lt;li&gt;For any bulk operation on a shared-hosting WordPress site, prefer many small requests over one big one. &lt;code&gt;max_execution_time&lt;/code&gt; will find you eventually.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Missing alt text is exactly the kind of thing that piles up silently on a store's product catalog too. I built &lt;a href="https://shop.lumnika.com/compliancesnap/?src=devto" rel="noopener noreferrer"&gt;ComplianceSnap&lt;/a&gt;, a free scanner for Shopify/WooCommerce stores that counts how many product images have no alt text and estimates your ADA/WCAG risk exposure.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>a11y</category>
      <category>php</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
