<?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: Alex Spinov </title>
    <description>The latest articles on DEV Community by Alex Spinov  (@0012303).</description>
    <link>https://dev.to/0012303</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%2F3831260%2F88c2b1ec-9abb-44c0-a6b8-774b9f415fce.PNG</url>
      <title>DEV Community: Alex Spinov </title>
      <link>https://dev.to/0012303</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/0012303"/>
    <language>en</language>
    <item>
      <title>9 Keyless Health APIs: You Asked for v1, You Got v8</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Wed, 29 Jul 2026 01:28:26 +0000</pubDate>
      <link>https://dev.to/0012303/9-keyless-health-apis-you-asked-for-v1-you-got-v8-1gh2</link>
      <guid>https://dev.to/0012303/9-keyless-health-apis-you-asked-for-v1-you-got-v8-1gh2</guid>
      <description>&lt;p&gt;I asked a keyless drug-label endpoint for version 1 of a document. Then version 3, version 8, and version 999. Here is what came back on July 27, 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?spl_version=1    HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8
?spl_version=3    HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8
?spl_version=8    HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8
?spl_version=999  HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four requests, one document, one md5. Not one of those lines is an error. The parameter is accepted, ignored, and never mentioned again.&lt;/p&gt;

&lt;p&gt;And there really are eight versions under that id. The API will happily list them for you. It just will not serve you any of them except the newest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;keyless health API&lt;/strong&gt; returns drug labels, trial registrations, provider records or compound data over plain HTTP with no key, no signup and no card. I checked nine of them with live requests on July 27, 2026, and the table is further down. The thing most of them share is not a missing field. It is that &lt;strong&gt;the id you store is stable while the record underneath it is not&lt;/strong&gt;, and the response gives you no way to notice.&lt;/p&gt;

&lt;p&gt;So the row in your database is not a fact about a drug label. It is a draft that the publisher can rewrite behind your back, under the same id, and your copy will keep looking exactly as correct as it did the day you fetched it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole proof, and the controls that had to fail
&lt;/h2&gt;

&lt;p&gt;Standard library, no key, no account. Copy it and you will get the same shape I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable, standard library only, needs network. No key, no signup, no card.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;

&lt;span class="n"&gt;DM&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://dailymed.nlm.nih.gov/dailymed/services/v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;RX&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://rxnav.nlm.nih.gov/REST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10d36b32-0202-4c03-8a1a-cd5d53f231e7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# one drug-label folder
&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0cb0ba76-067f-46c4-a5d8-b585d5ecafe3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# a different one, for controls
&lt;/span&gt;&lt;span class="n"&gt;ZEROS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;00000000-0000-0000-0000-000000000000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# fail loud, never silent
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DM&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/spls/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/history.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&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;total_elements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;version_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                                    &lt;span class="c1"&gt;# &amp;lt;versionNumber value="8"/&amp;gt;
&lt;/span&gt;    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;versionNumber\s+value=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;lt;ABSENT&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1. HOW MANY VERSIONS LIVE UNDER ONE id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   setid &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;...  HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; versions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&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;history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;       v&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;spl_version&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;published_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# control: data vs OTHER data
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   CONTROL other real setid &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;... -&amp;gt; HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total_b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; versions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="n"&gt;reads&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;constant&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; if total_b &lt;/span&gt;&lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FAIL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body_z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZEROS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# an id that never existed
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   an all-zeros setid       -&amp;gt; HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total_z&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; versions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;       body: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body_z&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;2. ASK FOR AN OLD VERSION. FINGERPRINT WHAT COMES BACK.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&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;3&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;8&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;999&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DM&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/spls/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.xml?spl_version=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;md5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;version_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;as asked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;-- asked &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, served &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   ?spl_version=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; B  md5 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;versionNumber=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   distinct documents behind those 4 requests: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DM&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/spls/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# control: data vs OTHER data
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   CONTROL a different setid -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; B  md5 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;tell&lt;/span&gt; &lt;span class="n"&gt;two&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="n"&gt;apart&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; if hashlib.md5(other).hexdigest() not in seen else &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="n"&gt;FAIL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DM&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/spls/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/3.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTML homepage, not a document&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;!DOCTYPE html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a document&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   path /3.xml       HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; B  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;versionNumber=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;version_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;-- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;impossible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;zzqqNotATag991 value=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   CONTROL matcher on a tag that cannot exist -&amp;gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;version_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;impossible&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  [PASS: the matcher can miss]&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   Not one line above is an error. Every line is HTTP 200.&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;3. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OBSOLETE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NEVER EXISTED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; ARE THE SAME RESPONSE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="o"&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;1801289&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;in use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;105078&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;obsolete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;351772&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;remapped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;999999999&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;never existed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rxcui&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;my label for it&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;properties.json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;historystatus.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;shapes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rxcui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;what&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RX&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/rxcui/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rxcui&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/properties.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RX&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/rxcui/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rxcui&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/historystatus.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hb&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rxcuiStatusHistory&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;metaData&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;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rxcui&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;what&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;status=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;what&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   COLLAPSED into one identical response: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   properties.json    -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; distinct answers for 4 distinct realities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   historystatus.json -&amp;gt; 4 distinct answers for the same 4 ids&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;Output on my machine, July 27, 2026, pasted as it came out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. HOW MANY VERSIONS LIVE UNDER ONE id
   setid 10d36b32...  HTTP 200, 946 bytes -&amp;gt; 8 versions
       v8    Jun 26, 2026
       v7    Nov 22, 2024
       v6    Dec 15, 2023
       v5    Oct 07, 2022
       v4    Jun 29, 2021
       v3    Jul 31, 2020
       v2    May 07, 2019
       v1    Aug 01, 2017
   CONTROL other real setid 0cb0ba76... -&amp;gt; HTTP 200, 619 bytes, 1 versions  [PASS: the probe reads the document, not a constant]
   an all-zeros setid       -&amp;gt; HTTP 200, 433 bytes, 0 versions
       body: {"spl": {"title": "", "setid": ""}, "history": []}

2. ASK FOR AN OLD VERSION. FINGERPRINT WHAT COMES BACK.
   ?spl_version=1    HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8          &amp;lt;-- asked 1, served 8
   ?spl_version=3    HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8          &amp;lt;-- asked 3, served 8
   ?spl_version=8    HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8          as asked
   ?spl_version=999  HTTP 200   266520 B  md5 4ae91da54b97  versionNumber=8          &amp;lt;-- asked 999, served 8
   distinct documents behind those 4 requests: 1
   CONTROL a different setid -&amp;gt;   95825 B  md5 1dc13a19afa1  [PASS: md5 can tell two documents apart]
   path /3.xml       HTTP 200    75305 B  versionNumber=&amp;lt;&amp;lt;ABSENT&amp;gt;&amp;gt; &amp;lt;-- HTML homepage, not a document
   CONTROL matcher on a tag that cannot exist -&amp;gt; &amp;lt;&amp;lt;ABSENT&amp;gt;&amp;gt;  [PASS: the matcher can miss]
   Not one line above is an error. Every line is HTTP 200.

3. 'OBSOLETE' AND 'NEVER EXISTED' ARE THE SAME RESPONSE
   rxcui       my label for it   properties.json           historystatus.json
   1801289     in use            HTTP 200, 270 bytes       status=Active
   105078      obsolete          HTTP 200, 2 bytes         status=Obsolete
   351772      remapped          HTTP 200, 2 bytes         status=Remapped
   999999999   never existed     HTTP 200, 2 bytes         status=UNKNOWN
   COLLAPSED into one identical response: obsolete, remapped, never existed
   properties.json    -&amp;gt; 2 distinct answers for 4 distinct realities
   historystatus.json -&amp;gt; 4 distinct answers for the same 4 ids
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A word about the controls, because I nearly shipped a weak one. My first version checked the history probe against a made-up setid and got zero versions back, which felt like proof the probe worked. It is not. That control only shows the probe can tell &lt;strong&gt;data from nothing&lt;/strong&gt;. It says nothing about whether the probe can tell &lt;strong&gt;data from other data&lt;/strong&gt;, which is the thing I was actually claiming. So both controls now compare two real documents: a second setid with a different version count, and an md5 that demonstrably differs across two real files before I trust it matching four times.&lt;/p&gt;

&lt;p&gt;The all-zeros line is worth a second look on its own. A setid made of nothing but zeros returns HTTP 200 with a complete envelope and an empty &lt;code&gt;history&lt;/code&gt; array. So "this record never existed" and "this record has no history" arrive as the same successful response. That is the article's problem viewed from the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  How often does a label actually get rewritten?
&lt;/h2&gt;

&lt;p&gt;Often enough that it is not an edge case. I sampled 80 setids from 8 random pages of the live index (seed 63, out of 158,178 records) and pulled the history of each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;history says rewritten (&amp;gt;1 version) : 49/80 = 61.3%
naive test spl_version &amp;gt; 1          : 56/80 = 70.0%
the two disagree on                 : 7/80 = 8.8%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly six in ten of the labels in that sample had been rewritten at least once under an id that never moved.&lt;/p&gt;

&lt;p&gt;Now look at the second line, because that is the test you would write. &lt;code&gt;spl_version&lt;/code&gt; sits right there in the list response, so counting rows where it exceeds 1 looks like a free answer. It disagrees with the real history on 7 of the 80, always in the direction of overcounting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;spl_version&lt;/code&gt; is not a revision count.&lt;/strong&gt; From that same sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spl_version=101     history has 2   version(s)   5c8cebcd...  PROPAFENONE HYDROCHLORIDE TABLET
spl_version=100     history has 1   version(s)   b4b7b936...  ENALAPRIL MALEATE TABLET
spl_version=100     history has 1   version(s)   c83daa7b...  DANTROLENE SODIUM CAPSULE
spl_version=100     history has 1   version(s)   1ebeb8ec...  RIVASTIGMINE TRANSDERMAL SYSTEM
spl_version=21      history has 8   version(s)   d1468c0f...  SODIUM CHLORIDE INJECTION
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three records carrying version 100 with exactly one version in their history. One carrying version 21 with eight. The number is a publisher's own counter, and comparing it across setids means nothing. The titles above are record labels and nothing more; none of this says anything about the products themselves.&lt;/p&gt;

&lt;p&gt;I want to be blunt about how I got here, because the wrong version of this section nearly went out. My first pass measured the rewrite rate with &lt;code&gt;spl_version &amp;gt; 1&lt;/code&gt; and produced a confident percentage. The very next probe killed the method: a record at version 100 with a single entry in its history proves the field does not mean what the name suggests. I threw the number away rather than publish it, and the 49 of 80 above comes from counting actual history entries instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nine endpoints, checked live
&lt;/h2&gt;

&lt;p&gt;Every row is a request I made on July 27, 2026 with no key, no account and no card. The last column is the point of the table: what, if anything, tells you the record has a past.&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;Provider&lt;/th&gt;
&lt;th&gt;Keyless endpoint&lt;/th&gt;
&lt;th&gt;What came back&lt;/th&gt;
&lt;th&gt;Version signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;DailyMed&lt;/strong&gt; (NLM)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dailymed.nlm.nih.gov/dailymed/services/v2/spls/{setid}/history.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;158,178 SPLs in the index; the sampled setid returned 8 versions in 946 bytes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;full history&lt;/strong&gt;, but old documents are not served&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;RxNorm / RxNav&lt;/strong&gt; (NLM)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rxnav.nlm.nih.gov/REST/rxcui/{id}/historystatus.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Active&lt;/code&gt;, &lt;code&gt;Obsolete&lt;/code&gt;, &lt;code&gt;Remapped&lt;/code&gt;, &lt;code&gt;UNKNOWN&lt;/code&gt; on the four ids I tried&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;status only&lt;/strong&gt;, and only on this endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ClinicalTrials.gov&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;clinicaltrials.gov/api/int/studies/{nct}/history&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NCT00001372 returned 395 versions in 86,963 bytes; NCT02576457 returned 27&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;full history&lt;/strong&gt; with a status per version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;openFDA&lt;/strong&gt; drug/label&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.fda.gov/drug/label.json?limit=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, 3,933 bytes, 260,986 labels in the index, &lt;code&gt;version: 2&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt; differs from &lt;code&gt;set_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;version number, no history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;WHO GHO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ghoapi.azureedge.net/api/WHOSIS_000001?$top=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, 689 bytes, 26 leaf fields, 6 of them time related&lt;/td&gt;
&lt;td&gt;none: every time field describes the observation, not the record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PubChem PUG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{name}/property/.../JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, 224 bytes, 4 leaf fields&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;NPI Registry&lt;/strong&gt; (CMS)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npiregistry.cms.hhs.gov/api/?version=2.1&amp;amp;number={npi}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;npi 1578736112: &lt;code&gt;enumeration_date&lt;/code&gt; 2008-04-03, &lt;code&gt;last_updated&lt;/code&gt; 2026-01-05&lt;/td&gt;
&lt;td&gt;one timestamp, sitting 18 years after enumeration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;HAPI FHIR R4&lt;/strong&gt; (public &lt;strong&gt;test&lt;/strong&gt; server)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hapi.fhir.org/baseR4/Patient/{id}/_history&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_history&lt;/code&gt; total 2: v1 POST at 13:06:56.678, v2 PUT at 13:06:57.560&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;versioning is in the protocol&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;disease.sh&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;disease.sh/v3/covid-19/all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, 478 bytes, 21 keys, one of them time related (&lt;code&gt;updated&lt;/code&gt;, epoch ms)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine services, no keys. Three of them list a record's past version by version: DailyMed, ClinicalTrials.gov, and any FHIR server. Two more hint at it without enumerating anything, and they hint differently: RxNorm tells you a concept was retired or remapped, openFDA ships a version number with nothing behind it. NPPES gives you two dates eighteen years apart and nothing in between, so you can see that something happened without seeing how many times. The remaining three say nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same provider, two endpoints, two different truths
&lt;/h2&gt;

&lt;p&gt;Block 3 of the output is the one I would tape to a wall. RxNorm's &lt;code&gt;properties.json&lt;/code&gt; returns &lt;strong&gt;2 distinct responses for 4 distinct realities&lt;/strong&gt;. An obsolete concept, a remapped concept and an id nobody ever issued all come back as HTTP 200 with a 2-byte body, &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Think about what that does to a pipeline. Your nightly job re-checks the ids you stored. One of them starts returning &lt;code&gt;{}&lt;/code&gt;. There is no status code to branch on, no error field, no message. From inside the client, "this identifier was retired" is indistinguishable from "you sent garbage", and both are indistinguishable from a typo in your own query.&lt;/p&gt;

&lt;p&gt;The information is not missing. It is one endpoint away. &lt;code&gt;historystatus.json&lt;/code&gt; answers all four cases separately and tells you the retirement month while it is at it. Nothing forces you to call it, and nothing warns you that you did not.&lt;/p&gt;

&lt;p&gt;ClinicalTrials.gov shows the same split with a sharper edge. The public v2 endpoint gives you the current record. The history endpoint gives you every version, and for NCT02576457 the status field walks &lt;code&gt;NOT_YET_RECRUITING&lt;/code&gt; to &lt;code&gt;RECRUITING&lt;/code&gt; to &lt;code&gt;ACTIVE_NOT_RECRUITING&lt;/code&gt; to &lt;code&gt;TERMINATED&lt;/code&gt; across 27 versions between 2015-10-14 and 2017-09-14. If you stored that row in 2016 and keyed it by NCT id, you are holding a value the source stopped agreeing with nine years ago, and your row looks fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not the &lt;code&gt;latest&lt;/code&gt; problem, and the usual fix does not apply
&lt;/h2&gt;

&lt;p&gt;A few days ago I wrote about &lt;a href="https://blog.spinov.online/blog/free-package-registry-apis-no-key/" rel="noopener noreferrer"&gt;package registries and why you cannot pin &lt;code&gt;latest&lt;/code&gt;&lt;/a&gt;. The prescription there was clear: never dereference a mutable alias in a build, resolve it once, and pin an exact version plus an integrity hash.&lt;/p&gt;

&lt;p&gt;I want to be direct about this, because it is my own advice and it does not survive contact with these APIs.&lt;/p&gt;

&lt;p&gt;On a package registry, version 1.2.3 is immutable and only the pointer moves, which is precisely why pinning rescues you. Here the pointer is all there is. DailyMed accepts &lt;code&gt;?spl_version=1&lt;/code&gt; and serves you version 8 with no complaint, so there is nothing to pin. The path form &lt;code&gt;/{setid}/3.xml&lt;/code&gt; is worse: HTTP 200, 75,305 bytes, and what arrives is the DailyMed homepage in HTML with no &lt;code&gt;&amp;lt;document&amp;gt;&lt;/code&gt; element anywhere in it. RxNorm's answer to an old identifier is &lt;code&gt;{}&lt;/code&gt;. You can learn that version 3 existed and when it was published. You cannot obtain it.&lt;/p&gt;

&lt;p&gt;So the two posts are not the same post. There, the archive exists and you have to aim at it correctly. Here the archive is announced and withheld, which means the only copy of version 3 you will ever have is the one you took on the day it was current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why my usual advice about upsert is wrong here
&lt;/h2&gt;

&lt;p&gt;I have also argued, in a &lt;a href="https://blog.spinov.online/blog/your-scraper-died-at-row-12000/" rel="noopener noreferrer"&gt;post about a scraper that died at row 12,000&lt;/a&gt;, that an upsert beats a blind append. I stand by that in its own context and it is the exact wrong move here, so let me draw the line rather than leave the two posts arguing with each other.&lt;/p&gt;

&lt;p&gt;Upsert is right when you are rewriting &lt;strong&gt;your own unfinished run&lt;/strong&gt;. The job crashed, you restart it, you re-fetch rows you already had, and you want one copy of each. Upsert is wrong when you are rewriting &lt;strong&gt;someone else's changed record&lt;/strong&gt;, because then the row you overwrite was evidence, and after the overwrite there is no trace that the source ever said anything different.&lt;/p&gt;

&lt;p&gt;The distinction is not upsert versus append. It is what you put in the key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable, standard library only, no network, no keys.
# Replays the two real histories printed above: folder A was rewritten 7 times,
# folder B never was. A crawler happened to visit on each publication day.
&lt;/span&gt;
&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-06-26&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-11-22&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2023-12-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;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;2022-10-07&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2021-06-29&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2020-07-31&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2019-05-07&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2017-08-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="o"&gt;=&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-24&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;visits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;What the crawler stores on each visit: whichever version was current.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="n"&gt;crawl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;visits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;append&lt;/span&gt; &lt;span class="o"&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;for&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crawl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;setid&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="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# keyed by id alone
&lt;/span&gt;    &lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;                 &lt;span class="c1"&gt;# keyed by (id, version)
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keyed_by_version&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyed_by_version&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after the crawl:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  upsert by id        A: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upsert&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; row   &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upsert&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; row   &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt; rewrites detected: A=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, B=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  append by (id, ver) A: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;append&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;append&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; row   &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt; rewrites detected: A=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, B=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;rewrites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  the upsert store answers &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;was this rewritten?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; identically for a folder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  rewritten 7 times and one never touched: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;same&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# The control that matters: re-run the last visit, the way a crashed job does.
&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crawl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
    &lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ver&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  replaying the last 3 visits after a crash: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;duplicates&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; if len(append) == before else &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="n"&gt;FAIL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# And a check that can fail, so it is a check and not decoration.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assert_history_survives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;setid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: kept &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; versions, source published &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;assert_history_survives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;assert_history_survives&lt;/span&gt;&lt;span class="p"&gt;({(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-06-26&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;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;AssertionError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  the same assertion on an upserted store -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output, same machine, July 27, 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;after the crawl:
  upsert by id        A: 1 row   B: 1 row   -&amp;gt; rewrites detected: A=0, B=0
  append by (id, ver) A: 8 rows  B: 1 row   -&amp;gt; rewrites detected: A=7, B=0

  the upsert store answers 'was this rewritten?' identically for a folder
  rewritten 7 times and one never touched: True

  replaying the last 3 visits after a crash: 9 -&amp;gt; 9 rows  [PASS: no duplicates]
  the same assertion on an upserted store -&amp;gt; A: kept 1 versions, source published 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the middle block. The upsert store gives the same answer, zero rewrites, for a folder that was rewritten seven times and for one that was never touched. That is not a small loss of detail. It is the destruction of the only difference that mattered, and the store cannot even report that it happened.&lt;/p&gt;

&lt;p&gt;Now read the line after it. Replaying the last three visits, the way a crashed job replays them, leaves the row count unchanged. &lt;strong&gt;The deduplication came from the key, not from the upsert.&lt;/strong&gt; Once &lt;code&gt;(id, version)&lt;/code&gt; is the key, the crash-safety argument from the row-12,000 post still holds and you keep the history as well. The two posts only conflict if you let the key stay at &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The last line is the assertion doing its job in the direction that matters: fed an upserted store, it fails loudly and names the gap. A check that cannot fail is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is a snapshot.&lt;/strong&gt; Nine services, one machine, one IP, July 27, 2026. I did not read anyone's terms and did not measure rate limits, which is why there is not a single limit number in this post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;80 setids is a sample, not a census.&lt;/strong&gt; 80 out of 158,178, from 8 random pages, seed 63. I did not compute a confidence interval, so treat 49 of 80 as an order of magnitude, not a national statistic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The title generalises from a small check, so here is the check.&lt;/strong&gt; The four-request fingerprint above is one setid. I then repeated it on ten further multi-version setids drawn from a different page of the index, comparing version 1 against each document's own latest version: &lt;strong&gt;10 of 10 returned an identical md5 for both&lt;/strong&gt;. Control: two different setids compared the same way do produce different md5s, so the comparison is capable of showing a difference. Eleven documents is still not the whole index of 158,178, and I have not tested a single one that behaved otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rewrite rate is DailyMed only.&lt;/strong&gt; I did not sample ClinicalTrials.gov or openFDA the same way, so nothing here says how often a trial record or an openFDA label changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HAPI FHIR is a public test server&lt;/strong&gt; that anyone on the internet can write to. The two versions I found were created 0.882 seconds apart, so that is somebody's test script, not clinical data. I include it because it shows the protocol carries &lt;code&gt;versionId&lt;/code&gt; and &lt;code&gt;_history&lt;/code&gt; natively, which is the counterexample to everything above, and for no other reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A search that returned nothing.&lt;/strong&gt; &lt;code&gt;Patient?_count=1&lt;/code&gt; on that same server gave me HTTP 200, 455 bytes, a &lt;code&gt;searchset&lt;/code&gt; bundle with a &lt;code&gt;next&lt;/code&gt; link and no &lt;code&gt;entry&lt;/code&gt; array at all. Reading a patient by id worked fine a second later. I have no explanation and I am not going to invent one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I did not audit disease.sh past the top-level object.&lt;/strong&gt; It answered with no key and it has no version field in that object. That is all I checked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;openFDA answered normally for me&lt;/strong&gt; on the one call I made. I did not test it under repetition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing here is clinical.&lt;/strong&gt; Every measurement in this post is about the behaviour of an API as a data source. Drug and trial names appear only as record identifiers, and no statement here concerns any product, treatment or study on its merits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance.&lt;/strong&gt; I run scrapers and data pipelines in production, 32 published actors and something over 2,000 runs by my own count. The habit behind this post came from enrichment feeds, which lie with a confident 200 far more often than they fail honestly. These nine checks are one sitting, not a production sample, and I am not going to dress them up as one.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Here is the part I have not solved. DailyMed will tell me that version 3 of a document existed and was published on 2020-07-31. It will not give me version 3. So the only way to ever hold those bytes is to have been crawling on a day when version 3 was current, and to have kept them.&lt;/p&gt;

&lt;p&gt;That turns a cheap decision into an expensive one. Storing &lt;code&gt;(id, version, hash, seen_at)&lt;/code&gt; costs almost nothing and lets me prove that something changed. Storing the full document on every fetch lets me prove &lt;strong&gt;what&lt;/strong&gt; changed, and at a quarter of a megabyte for the one label I measured, across an index of 158,178, that is a real bill for a question nobody has asked me yet.&lt;/p&gt;

&lt;p&gt;I currently keep hashes for everything and full bodies only for the handful of sources I have been burned by. It feels like the wrong line, drawn from memory of past incidents rather than from anything principled.&lt;/p&gt;

&lt;p&gt;If you pull records from a source that rewrites them in place: do you snapshot every fetch, or keep only the fingerprint and accept that you can prove a change without ever being able to show it? Tell me in the comments, I read every one. 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance. Every status code, byte count, md5, row count and command output above comes from my own live requests and negative controls on July 27, 2026, printed as they came out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "ItemList",&lt;br&gt;
  "name": "9 Free Health and Medical APIs With No Key (2026)",&lt;br&gt;
  "description": "Nine health and medical APIs that need no API key, no signup and no card, each checked with a live request on July 27, 2026, together with what each response does or does not tell you about whether the record under a stable id has been rewritten since it was published.",&lt;br&gt;
  "itemListOrder": "&lt;a href="https://schema.org/ItemListOrderAscending" rel="noopener noreferrer"&gt;https://schema.org/ItemListOrderAscending&lt;/a&gt;",&lt;br&gt;
  "numberOfItems": 9,&lt;br&gt;
  "itemListElement": [&lt;br&gt;
    {"@type": "ListItem", "position": 1, "name": "DailyMed (US National Library of Medicine)", "url": "&lt;a href="https://dailymed.nlm.nih.gov/dailymed/services/v2/spls.json" rel="noopener noreferrer"&gt;https://dailymed.nlm.nih.gov/dailymed/services/v2/spls.json&lt;/a&gt;", "description": "Keyless access to structured product labels. The index held 158,178 SPLs on July 27, 2026. A history endpoint at /spls/{setid}/history.json lists every version under a setid: one sampled setid returned 8 versions in 946 bytes, published between August 1, 2017 and June 26, 2026. Old versions are listed but not served: requesting the document with ?spl_version=1, 3, 8 or 999 returned four byte-identical responses of 266,520 bytes with md5 4ae91da54b97, each carrying versionNumber 8 and HTTP 200, and the path form /{setid}/3.xml returned HTTP 200 with 75,305 bytes of the DailyMed HTML homepage. A setid made entirely of zeros returns HTTP 200 with an empty history array rather than a 404. In a sample of 80 setids from 8 random pages, 49 had more than one version, while the naive test spl_version greater than 1 flagged 56 and disagreed with the real history on 7, because spl_version is a publisher counter rather than a revision count: three sampled records showed version 100 with a single version in their history."},&lt;br&gt;
    {"@type": "ListItem", "position": 2, "name": "RxNorm and RxNav (US National Library of Medicine)", "url": "&lt;a href="https://rxnav.nlm.nih.gov/REST/rxcui/1801289/historystatus.json" rel="noopener noreferrer"&gt;https://rxnav.nlm.nih.gov/REST/rxcui/1801289/historystatus.json&lt;/a&gt;", "description": "Keyless drug terminology lookups. The plain properties.json endpoint returns HTTP 200 with a 2-byte empty object for an obsolete concept, a remapped concept and an identifier that never existed, giving 2 distinct responses for 4 distinct realities, while a valid active concept returns 270 bytes. The historystatus.json endpoint distinguishes all four, returning status Active, Obsolete, Remapped and UNKNOWN respectively, and includes the month a concept was retired or remapped."},&lt;br&gt;
    {"@type": "ListItem", "position": 3, "name": "ClinicalTrials.gov API v2", "url": "&lt;a href="https://clinicaltrials.gov/api/v2/studies/NCT00001372" rel="noopener noreferrer"&gt;https://clinicaltrials.gov/api/v2/studies/NCT00001372&lt;/a&gt;", "description": "Keyless access to trial registrations. The v2 study endpoint returns the current record only, carrying studyFirstSubmitDate and lastUpdateSubmitDate; a nonexistent NCT number returns HTTP 404 with a 32-byte plain-text body. A separate history endpoint at /api/int/studies/{nct}/history returns every version: NCT00001372 returned 395 versions in 86,963 bytes, and NCT02576457 returned 27 versions in which the status field moved from NOT_YET_RECRUITING to RECRUITING to ACTIVE_NOT_RECRUITING to TERMINATED between October 14, 2015 and September 14, 2017."},&lt;br&gt;
    {"@type": "ListItem", "position": 4, "name": "openFDA drug label API", "url": "&lt;a href="https://api.fda.gov/drug/label.json?limit=1" rel="noopener noreferrer"&gt;https://api.fda.gov/drug/label.json?limit=1&lt;/a&gt;", "description": "Keyless access to FDA drug label data, 260,986 labels in the index on July 27, 2026. A single record returned HTTP 200 in 3,933 bytes carrying a version field (value 2), an effective_time, and both an id and a set_id whose values differ, which is the same folder-versus-document split DailyMed exposes. The endpoint returns the current version only and offers no history listing."},&lt;br&gt;
    {"@type": "ListItem", "position": 5, "name": "WHO Global Health Observatory OData API", "url": "&lt;a href="https://ghoapi.azureedge.net/api/WHOSIS_000001?$top=1" rel="noopener noreferrer"&gt;https://ghoapi.azureedge.net/api/WHOSIS_000001?$top=1&lt;/a&gt;", "description": "Keyless OData access to WHO health indicators. A single observation returned HTTP 200 in 689 bytes with 26 leaf fields, of which 6 are time related: TimeDimType, TimeDim, Date, TimeDimensionValue, TimeDimensionBegin and TimeDimensionEnd. All six describe the period the observation covers rather than the revision state of the record, so there is no way from the payload to tell whether a value was restated after publication."},&lt;br&gt;
    {"@type": "ListItem", "position": 6, "name": "PubChem PUG REST", "url": "&lt;a href="https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/aspirin/property/MolecularFormula,MolecularWeight,ConnectivitySMILES/JSON" rel="noopener noreferrer"&gt;https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/aspirin/property/MolecularFormula,MolecularWeight,ConnectivitySMILES/JSON&lt;/a&gt;", "description": "Keyless chemical compound properties. A three-property request returned HTTP 200 in 224 bytes with 4 leaf fields (CID plus the three requested properties) and no version, revision or update field of any kind. The same field-counting probe finds 3 version-related fields in a DailyMed response, so the absence here is a measured zero rather than a probe that cannot count."},&lt;br&gt;
    {"@type": "ListItem", "position": 7, "name": "NPPES NPI Registry (US Centers for Medicare and Medicaid Services)", "url": "&lt;a href="https://npiregistry.cms.hhs.gov/api/?version=2.1&amp;amp;number=1578736112" rel="noopener noreferrer"&gt;https://npiregistry.cms.hhs.gov/api/?version=2.1&amp;amp;amp;number=1578736112&lt;/a&gt;", "description": "Keyless lookups of US healthcare provider identifiers. A real NPI returned HTTP 200 with result_count 1, an enumeration_date of 2008-04-03 and a last_updated of 2026-01-05, which is two dates roughly 18 years apart with nothing in between them: no version number, no revision count and no history endpoint. A nonexistent NPI returns HTTP 200 with result_count 0 in 31 bytes rather than a 404."},&lt;br&gt;
    {"@type": "ListItem", "position": 8, "name": "HAPI FHIR R4 public test server", "url": "&lt;a href="https://hapi.fhir.org/baseR4" rel="noopener noreferrer"&gt;https://hapi.fhir.org/baseR4&lt;/a&gt;", "description": "A public FHIR R4 test server, open for reads and writes to anyone, included as a protocol counterexample rather than as a data source. Every resource carries meta.versionId and every resource exposes a _history endpoint: one patient read returned versionId 2, and its _history returned a bundle of total 2 in which version 1 was a POST and version 2 a PUT logged 0.882 seconds later, which is somebody's test write rather than clinical data. A separate Patient search with _count=1 returned HTTP 200 in 455 bytes as a searchset bundle carrying a next link and no entry array at all."},&lt;br&gt;
    {"@type": "ListItem", "position": 9, "name": "disease.sh", "url": "&lt;a href="https://disease.sh/v3/covid-19/all" rel="noopener noreferrer"&gt;https://disease.sh/v3/covid-19/all&lt;/a&gt;", "description": "Keyless aggregated public-health counts. The global summary returned HTTP 200 in 478 bytes with 21 top-level keys, of which exactly one is time related: updated, an epoch timestamp in milliseconds. There is no version field and no history endpoint, so a restated figure and an original figure are indistinguishable once stored. Fields below the top-level object were not audited."}&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "&lt;a class="mentioned-user" href="https://dev.to/context"&gt;@context&lt;/a&gt;": "&lt;a href="https://schema.org" rel="noopener noreferrer"&gt;https://schema.org&lt;/a&gt;",&lt;br&gt;
  "@type": "FAQPage",&lt;br&gt;
  "mainEntity": [&lt;br&gt;
    {"@type": "Question", "name": "Which health and medical APIs work with no key?", "acceptedAnswer": {"@type": "Answer", "text": "Nine were checked with live requests on July 27, 2026: DailyMed, RxNorm and RxNav, ClinicalTrials.gov, openFDA drug labels, the WHO Global Health Observatory, PubChem PUG REST, the CMS NPI Registry, the public HAPI FHIR R4 test server and disease.sh. None required a key, a signup or a card. The HAPI FHIR server is a public test instance that anyone can write to and is listed as a protocol example, not as a data source."}},&lt;br&gt;
    {"@type": "Question", "name": "How do I know if a health API record changed after I stored it?", "acceptedAnswer": {"@type": "Answer", "text": "Usually not from the record itself. You need a separate history endpoint, and only some providers publish one: DailyMed at /spls/{setid}/history.json, ClinicalTrials.gov at /api/int/studies/{nct}/history, and any FHIR server at resource/{id}/_history. Compare the number of versions rather than the value of a field, because a version field can move without the history growing. openFDA, the NPI Registry, PubChem, the WHO GHO and disease.sh published no history endpoint at all when checked on July 27, 2026."}},&lt;br&gt;
    {"@type": "Question", "name": "Is spl_version the number of times a DailyMed drug label was revised?", "acceptedAnswer": {"@type": "Answer", "text": "No. In a sample of 80 setids taken on July 27, 2026, testing spl_version greater than 1 flagged 56 records as rewritten while the actual history endpoint showed 49, a disagreement on 7 of the 80. Three sampled records carried spl_version 100 with exactly one version in their history, and one carried spl_version 21 with eight versions in its history. The field is a publisher's own counter and is not comparable across setids."}},&lt;br&gt;
    {"@type": "Question", "name": "Can I fetch an older version of a DailyMed SPL over the API?", "acceptedAnswer": {"@type": "Answer", "text": "No. Requesting the same document with ?spl_version=1, 3, 8 and 999 on July 27, 2026 returned four byte-identical responses of 266,520 bytes sharing md5 4ae91da54b97, every one of them carrying versionNumber 8 and HTTP 200, so the parameter is accepted and ignored without any error. The path form /spls/{setid}/3.xml returned HTTP 200 with 75,305 bytes of the DailyMed HTML homepage rather than a document. The history endpoint tells you which versions exist and when they were published, but the documents themselves are not retrievable. The same check repeated on ten further multi-version setids from a different index page returned an identical md5 for version 1 and for the latest version in all ten cases."}},&lt;br&gt;
    {"@type": "Question", "name": "Why does an RxNorm rxcui return an empty object with HTTP 200?", "acceptedAnswer": {"@type": "Answer", "text": "Because the properties.json endpoint answers identically for three different situations. On July 27, 2026 an obsolete concept, a remapped concept and an identifier that never existed all returned HTTP 200 with a 2-byte empty object, while an active concept returned 270 bytes, giving 2 distinct responses for 4 distinct realities. The historystatus.json endpoint on the same identifiers returned four different answers: Active, Obsolete, Remapped and UNKNOWN. The information exists, it is simply not in the endpoint most clients call."}},&lt;br&gt;
    {"@type": "Question", "name": "What is the right primary key for a scraped medical record?", "acceptedAnswer": {"@type": "Answer", "text": "The pair (id, version) plus the timestamp of your own observation. A setid, an rxcui or an NCT number identifies a folder rather than a document, which DailyMed makes literal: the setId element stays constant while the id element changes with every version, and openFDA ships both an id and a set_id whose values differ. Keying on the identifier alone makes a record that was rewritten seven times indistinguishable from one that was never touched."}},&lt;br&gt;
    {"@type": "Question", "name": "Should I upsert scraped rows by source id?", "acceptedAnswer": {"@type": "Answer", "text": "Only when you are rewriting your own unfinished run, such as re-fetching rows after a crashed job, where an upsert correctly removes your duplicate. For a record the source can change, an upsert keyed on id alone deletes the only evidence that it changed. Key on (id, version) and append instead: replaying the same visits after a crash still produces no duplicates, because the deduplication comes from the key rather than from the upsert, and the version history survives."}}&lt;br&gt;
  ]&lt;br&gt;
}&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>data</category>
    </item>
    <item>
      <title>10 Keyless Statistics APIs: Never Delete the World Row</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Mon, 27 Jul 2026 03:48:47 +0000</pubDate>
      <link>https://dev.to/0012303/10-keyless-statistics-apis-never-delete-the-world-row-4h9o</link>
      <guid>https://dev.to/0012303/10-keyless-statistics-apis-never-delete-the-world-row-4h9o</guid>
      <description>&lt;p&gt;One indicator, one year, one keyless endpoint. I asked the World Bank for population in 2023, got 264 rows with a value, and added them up four different ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;naive sum of every row         86,466,868,911  10.7240x
deny-list on countryiso3code   16,102,473,551   1.9971x
allow-list on country.id        8,039,550,134   0.9971x
the WLD row itself              8,062,923,417   1.0000x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line one is a joke and nobody ships it. Line two is the one that ends up in a report, because 8 billion people looks like 8 billion people until you notice there are two of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;keyless statistics API&lt;/strong&gt; returns indicators by country and year with no API key, no signup and no card. I checked ten of them with live requests on July 26, 2026, and the table is two screens down. The catch they mostly share: totals and members arrive in one list, in one schema, and on eight of the ten the data rows carry no column that says which is which. "World" is a row. So is "High income". So is Belgium. Two of the ten do carry that column, and I owe them the credit: WHO GHO and UN Comtrade. There is a section on them below, because the exception is the useful part.&lt;/p&gt;

&lt;p&gt;Your instinct will be to filter the totals out. Mine was. That instinct is what produced the second line above, and the second line is worse than the first.&lt;/p&gt;

&lt;p&gt;Better move: keep the total row and make it a test. The row that ruins your sum is the only free check on your own filter that you will ever be handed.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the probes that had to fail
&lt;/h2&gt;

&lt;p&gt;If a checking script only ever reports success, it is not a check, it is a decoration. So before the first 200 I made the channel fail in as many ways as I could, on July 26, 2026:&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;Probe&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;N1&lt;/td&gt;
&lt;td&gt;host that does not exist, &lt;code&gt;api-zzqq9182-nosuchhost-77.org&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;curl exit &lt;code&gt;35&lt;/code&gt;, HTTP &lt;code&gt;000&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N2&lt;/td&gt;
&lt;td&gt;real host, junk path, &lt;code&gt;api.worldbank.org/v2/ZZQQNOSUCHPATH9182&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP &lt;code&gt;404&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N3&lt;/td&gt;
&lt;td&gt;real host, junk indicator &lt;code&gt;ZZ.QQ.NOSUCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP &lt;code&gt;200&lt;/code&gt; plus &lt;code&gt;{"id":"120","key":"Invalid value"}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N4&lt;/td&gt;
&lt;td&gt;FRED without a key&lt;/td&gt;
&lt;td&gt;HTTP &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;Variable api_key is not set&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N5&lt;/td&gt;
&lt;td&gt;US Census &lt;code&gt;api.census.gov/data/2023/pep/population?get=NAME,POP_2023&amp;amp;for=state:*&lt;/code&gt; without a key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;302&lt;/code&gt; then HTTP &lt;code&gt;200&lt;/code&gt; on &lt;code&gt;missing_key.html&lt;/code&gt; (the bare path with no query returns a plain &lt;code&gt;404&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N6&lt;/td&gt;
&lt;td&gt;BEA without a key&lt;/td&gt;
&lt;td&gt;HTTP &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;content-length: 0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N7&lt;/td&gt;
&lt;td&gt;WHO GHO with &lt;code&gt;$top=2000&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HTTP &lt;code&gt;400&lt;/code&gt;; at &lt;code&gt;$top=500&lt;/code&gt; it returns 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven probes, five shapes of failure: a dead socket, a 404, a 200 carrying an error object, a 400 with an English sentence, and a 200 carrying nothing at all. So "keyless" here means "I called it with no credentials and got data", never "the docs said free".&lt;/p&gt;

&lt;p&gt;That is also why three services people list as free statistics APIs are missing from my ten. FRED, BEA and the US Census all want a key, and they say so in three different ways. Only one of them uses an error status to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10 keyless endpoints, checked live
&lt;/h2&gt;

&lt;p&gt;Every row below is a request I made on July 26, 2026 with no key, no account and no card. The "level signal" column is the whole point of the article: it says what, if anything, in the response tells you whether a row is a member or a total.&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;Provider&lt;/th&gt;
&lt;th&gt;Keyless endpoint&lt;/th&gt;
&lt;th&gt;Rows I got&lt;/th&gt;
&lt;th&gt;Level signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;World Bank Indicators&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.worldbank.org/v2/country/all/indicator/SP.POP.TOTL?date=2023&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;265 entities, 264 with a value&lt;/td&gt;
&lt;td&gt;none in the data row; a separate &lt;code&gt;/v2/country&lt;/code&gt; call carries &lt;code&gt;region.value == "Aggregates"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Eurostat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nama_10_gdp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;46 geo entries for 2023&lt;/td&gt;
&lt;td&gt;none; aggregates are recognizable only by code (&lt;code&gt;EU27_2020&lt;/code&gt;, &lt;code&gt;EA&lt;/code&gt;, &lt;code&gt;EA19&lt;/code&gt;, &lt;code&gt;EA20&lt;/code&gt;, &lt;code&gt;EA21&lt;/code&gt;, &lt;code&gt;EA12&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;IMF DataMapper&lt;/td&gt;
&lt;td&gt;&lt;code&gt;imf.org/external/datamapper/api/v1/NGDPD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;229 codes in one flat object, 227 with 2023&lt;/td&gt;
&lt;td&gt;none inline; separate &lt;code&gt;/countries&lt;/code&gt; (241), &lt;code&gt;/groups&lt;/code&gt; (129) and &lt;code&gt;/regions&lt;/code&gt; (27) endpoints exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Our World in Data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ourworldindata.org/grapher/population.csv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;257 entities for 2023&lt;/td&gt;
&lt;td&gt;naming convention only: &lt;code&gt;OWID_&lt;/code&gt; and &lt;code&gt;UN_&lt;/code&gt; prefixes, with exceptions in both directions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;DBnomics&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.db.nomics.world/v22/series/WB/WDI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dimension named &lt;code&gt;country&lt;/code&gt; with 266 codes&lt;/td&gt;
&lt;td&gt;none; &lt;code&gt;WLD&lt;/code&gt; = World and &lt;code&gt;HIC&lt;/code&gt; = High income sit inside a dimension called &lt;code&gt;country&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;WHO GHO&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ghoapi.azureedge.net/api/WHOSIS_000001?$top=500&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;500 rows&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;explicit&lt;/strong&gt;: &lt;code&gt;SpatialDimType&lt;/code&gt; on every row, plus &lt;code&gt;ParentLocation&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;ILOSTAT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rplumber.ilo.org/data/indicator/?id=UNE_2EAP_SEX_AGE_RT_A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;91,692 rows, 276 distinct areas&lt;/td&gt;
&lt;td&gt;only with &lt;code&gt;type=code&lt;/code&gt;: aggregates are &lt;code&gt;X01&lt;/code&gt;, &lt;code&gt;X06&lt;/code&gt;, &lt;code&gt;X21&lt;/code&gt;; &lt;code&gt;type=label&lt;/code&gt; erases it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;UN Comtrade (preview)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;comtradeapi.un.org/public/v1/preview/C/A/HS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;224 rows for US exports, 2023&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;partnerCode == 0&lt;/code&gt; means world; &lt;code&gt;isAggregate&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; on all 224 at &lt;code&gt;cmdCode=TOTAL&lt;/code&gt;, and &lt;code&gt;true&lt;/code&gt; on exactly the world row at a leaf code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;UNESCO UIS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.uis.unesco.org/api/public/definitions/geounits&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;462 units: 241 &lt;code&gt;NATIONAL&lt;/code&gt;, 221 &lt;code&gt;REGIONAL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;explicit&lt;/strong&gt;, but in the catalogue call, not in the data rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;UK ONS (beta)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.beta.ons.gov.uk/v1/datasets/regional-gdp-by-year/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12 geographies, 23 industry codes&lt;/td&gt;
&lt;td&gt;none; England (&lt;code&gt;UK0&lt;/code&gt;) sits in the same list as its own nine regions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten sources, no keys. Two of them (WHO GHO, UNESCO UIS) will tell you the level outright. One (IMF) will tell you in a second call. One (ILOSTAT) will tell you only if you ask for codes instead of labels. The other six leave it to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The World Bank arithmetic, in full
&lt;/h2&gt;

&lt;p&gt;Here is the whole thing, runnable, standard library only, one network call each to two endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable, standard library only, needs network
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;UA&lt;/span&gt; &lt;span class="o"&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;User-Agent&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;aggregate-check/1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# fail loud, never a silent default
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;IND&lt;/span&gt; &lt;span class="o"&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;https://api.worldbank.org/v2/country/all/indicator/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SP.POP.TOTL?format=json&amp;amp;date=2023&amp;amp;per_page=400&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;REF&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://api.worldbank.org/v2/country?format=json&amp;amp;per_page=400&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IND&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ref&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REF&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="n"&gt;AGG_ISO3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aggregates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;AGG_ISO2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iso2Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aggregates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;CTY_ISO2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iso2Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aggregates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;CTY_ISO3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aggregates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;world&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1W&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;naive&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AGG_ISO3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;allow3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CTY_ISO3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;allow&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CTY_ISO2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;naive sum of every row&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;naive&lt;/span&gt;&lt;span class="p"&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;deny-list on countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deny&lt;/span&gt;&lt;span class="p"&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;allow-list on countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow3&lt;/span&gt;&lt;span class="p"&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;allow-list on country.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&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;the WLD row itself&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;9.4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;leak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AGG_ISO3&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AGG_ISO2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;aggregates that survived the deny-list: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leak&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;leak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;country&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;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  iso3=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;!r:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;country&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;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  their sum &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;leak&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  WLD row   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output on my machine, July 26, 2026, pasted as it came out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;naive sum of every row         86,466,868,911  10.7240x
deny-list on countryiso3code   16,102,473,551   1.9971x
allow-list on countryiso3code   8,039,550,134   0.9971x
allow-list on country.id        8,039,550,134   0.9971x
the WLD row itself              8,062,923,417   1.0000x

aggregates that survived the deny-list: 4
  id=XT  iso3=''  Upper middle income    3,057,927,413
  id=XN  iso3=''  Lower middle income    2,869,695,351
  id=XD  iso3=''  High income            1,408,138,595
  id=XM  iso3=''  Low income               727,162,058
  their sum   8,062,923,417
  WLD row     8,062,923,417
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last two lines twice. The four income groups sum to &lt;code&gt;8,062,923,417&lt;/code&gt;, and the World row is &lt;code&gt;8,062,923,417&lt;/code&gt;. Not close. Equal, to the person. They are a complete partition of the planet, so they contribute exactly one extra Earth, and they do it while wearing an empty &lt;code&gt;countryiso3code&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The catalogue knows about them. &lt;code&gt;/v2/country&lt;/code&gt; returns 295 records, 78 marked &lt;code&gt;region.value == "Aggregates"&lt;/code&gt;, and the four income groups sit there with three-letter ids: &lt;code&gt;HIC&lt;/code&gt;, &lt;code&gt;LIC&lt;/code&gt;, &lt;code&gt;LMC&lt;/code&gt;, &lt;code&gt;UMC&lt;/code&gt;. The World Bank has a perfectly good alpha-3 code for High income. The indicator payload just sends an empty string instead. Same entity, an identity in the catalogue and none in the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 1.9971x is worse than 10.72x
&lt;/h2&gt;

&lt;p&gt;Ten times the world population is not a bug you ship. Somebody glances at the chart, sees 86 billion, and the whole thing gets thrown back at you before lunch.&lt;/p&gt;

&lt;p&gt;Twice the world population is different. Nobody knows the planet's headcount to the digit. 16.1 billion in a cell labelled "total population covered" reads as a big number in a big dataset, and it passes the two-second sniff test that a human actually performs, which is "is this the right order of magnitude". You do not get caught. You get cited.&lt;/p&gt;

&lt;p&gt;And the deny-list feels like diligence. You looked up the aggregate codes, you excluded them, the number dropped by a factor of five and stopped looking absurd. Every signal you have says the repair worked.&lt;/p&gt;

&lt;p&gt;UN Comtrade makes the same point without the rounding. I asked for US exports in 2023, all partners, commodity total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows returned                    224
partnerCode == 0 (the world)     2,018,542,583,771
sum of the other 223 partners    2,018,542,583,771
sum of all 224 rows              4,037,085,167,542   2.0000x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two trillion dollars of exports, counted twice, on a response where the sum of the parts equals the declared total to the dollar. If you had not looked, the only tell would have been that your figure was too round.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field you would reach for, and three ways it goes wrong
&lt;/h2&gt;

&lt;p&gt;There is usually something in the payload that looks like the answer. Three of the ten taught me three different lessons about it: one where the field is present and empty, one where the naming convention leaks in both directions, and one where the field was right the whole time and my own query blinded it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;World Bank: the field is present and empty.&lt;/strong&gt; &lt;code&gt;countryiso3code&lt;/code&gt; is a fine key for 260 of the 264 rows. On four it is &lt;code&gt;""&lt;/code&gt;. A deny-list ("skip rows whose code is a known aggregate") lets the empty string through. An allow-list ("keep rows whose code is a known country") does not, because &lt;code&gt;""&lt;/code&gt; is not a country either.&lt;/p&gt;

&lt;p&gt;Same field, same reference table, opposite outcomes: lines two and three of the output above, &lt;code&gt;1.9971x&lt;/code&gt; against &lt;code&gt;0.9971x&lt;/code&gt;. The only difference is which direction you wrote the test in. If you would rather key on something never blank, &lt;code&gt;country.id&lt;/code&gt; is a two-character code present on all 265 rows: alpha-2 for economies, World Bank codes like &lt;code&gt;1W&lt;/code&gt;, &lt;code&gt;XD&lt;/code&gt; and &lt;code&gt;S1&lt;/code&gt; for aggregates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UN Comtrade: the field works, and my query broke it.&lt;/strong&gt; There is a boolean called &lt;code&gt;isAggregate&lt;/code&gt;, and on my request it was &lt;code&gt;true&lt;/code&gt; on all 224 rows: on the world row, on Canada, on Mexico, on China. I first wrote that up as a useless field. That was wrong, and checking it is what showed me why. Ask the same endpoint for a leaf commodity instead of the total (&lt;code&gt;cmdCode=270900&lt;/code&gt;, crude petroleum) and the same field returns 42 rows with &lt;code&gt;isAggregate&lt;/code&gt; &lt;code&gt;true&lt;/code&gt; on exactly one of them, the &lt;code&gt;partnerCode=0&lt;/code&gt; world row, and &lt;code&gt;false&lt;/code&gt; on the other 41. The field marks aggregation along whichever axis you asked to aggregate. &lt;code&gt;cmdCode=TOTAL&lt;/code&gt; is itself an aggregate over every commodity, so every row I got back was an aggregate, and the field said so correctly. The lesson is not "the flag lies." It is that a flag answers the question your query asked, not the question in your head.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our World in Data: the convention leaks in both directions.&lt;/strong&gt; OWID prefixes aggregates with &lt;code&gt;OWID_&lt;/code&gt;. Filter on that and you catch 13 rows, then land on &lt;code&gt;17,222,487,438&lt;/code&gt;, which is &lt;code&gt;2.1284x&lt;/code&gt; the World row. Drop the one row that carries no code at all (&lt;code&gt;Americas (UN)&lt;/code&gt;, &lt;code&gt;1,041,794,257&lt;/code&gt;) and you get &lt;code&gt;16,180,693,181&lt;/code&gt;, &lt;code&gt;1.9997x&lt;/code&gt;. Two operations, not one, and only the second gets you to the number that looks defensible. Six more aggregates use a &lt;code&gt;UN_&lt;/code&gt; prefix instead: &lt;code&gt;UN_ASI&lt;/code&gt;, &lt;code&gt;UN_AFR&lt;/code&gt;, &lt;code&gt;UN_EUR&lt;/code&gt;, &lt;code&gt;UN_LAC&lt;/code&gt;, &lt;code&gt;UN_NAM&lt;/code&gt;, &lt;code&gt;UN_OCE&lt;/code&gt;. Those six sum to &lt;code&gt;8,091,734,921&lt;/code&gt; against a World row of &lt;code&gt;8,091,734,933&lt;/code&gt;. Twelve people apart.&lt;/p&gt;

&lt;p&gt;The leak runs the other way too. &lt;code&gt;OWID_KOS&lt;/code&gt; is Kosovo, a country wearing the aggregate prefix because it has no alpha-3 code, and one row (&lt;code&gt;Americas (UN)&lt;/code&gt;) has no code at all. Drop everything prefixed and you delete a country; keep everything ISO3-shaped and you keep six continents.&lt;/p&gt;

&lt;p&gt;Eurostat adds a fourth flavour, less a lie than a crowd. The &lt;code&gt;nama_10_gdp&lt;/code&gt; geo dimension for 2023 has 46 entries, six of them aggregates, five of those overlapping euro-area vintages. Two are byte-identical: &lt;code&gt;EA&lt;/code&gt; and &lt;code&gt;EA20&lt;/code&gt; both report &lt;code&gt;14,666,808.1&lt;/code&gt; million euro. Sum all 46 and you get &lt;code&gt;6.3817x&lt;/code&gt; the EU27 total. DBnomics, meanwhile, puts &lt;code&gt;WLD&lt;/code&gt; and &lt;code&gt;HIC&lt;/code&gt; inside a dimension whose name is literally &lt;code&gt;country&lt;/code&gt;. 266 codes, one namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who does label the level
&lt;/h2&gt;

&lt;p&gt;The honest version of this post is not "nobody marks the totals". Somebody does, and the difference is one field. Two of the ten carry it in the data rows: UN Comtrade, from the section above, once you stop asking it a question whose answer is always yes, and this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WHO GHO puts it on every row.&lt;/strong&gt; Life expectancy at birth, &lt;code&gt;WHOSIS_000001&lt;/code&gt;, first 500 rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SpatialDimType: COUNTRY 478 | REGION 11 | WORLDBANKINCOMEGROUP 8 | GLOBAL 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One column, four values, no catalogue call, no naming convention, no join. Every row states its own level. There is a &lt;code&gt;ParentLocation&lt;/code&gt; field too, so a row also tells you what it rolls up into. That is the whole fix, shipped upstream, by an organisation with at least as much committee overhead as the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UNESCO UIS states it in the catalogue.&lt;/strong&gt; &lt;code&gt;/definitions/geounits&lt;/code&gt; returns 462 entries, each with a &lt;code&gt;type&lt;/code&gt;: 241 &lt;code&gt;NATIONAL&lt;/code&gt; and 221 &lt;code&gt;REGIONAL&lt;/code&gt;. Clear, but it is a separate call, and the data rows come back as &lt;code&gt;{"indicatorId", "geoUnit", "year", "value"}&lt;/code&gt; with no level in sight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The IMF ships three lists.&lt;/strong&gt; &lt;code&gt;/countries&lt;/code&gt; (241), &lt;code&gt;/groups&lt;/code&gt; (129) and &lt;code&gt;/regions&lt;/code&gt; (27) are all keyless. The indicator payload itself is one flat object where &lt;code&gt;WEOWORLD&lt;/code&gt;, &lt;code&gt;ADVEC&lt;/code&gt;, &lt;code&gt;EU&lt;/code&gt; and &lt;code&gt;USA&lt;/code&gt; are peers; sum all 227 codes with a 2023 value for &lt;code&gt;NGDPD&lt;/code&gt; and you get &lt;code&gt;671,589.964&lt;/code&gt; billion USD against a &lt;code&gt;WEOWORLD&lt;/code&gt; of &lt;code&gt;107,245.572&lt;/code&gt;, which is &lt;code&gt;6.2622x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ILOSTAT encodes it, then offers to throw it away.&lt;/strong&gt; With &lt;code&gt;type=code&lt;/code&gt; you get 276 areas: 87 start with &lt;code&gt;X&lt;/code&gt; (&lt;code&gt;X01&lt;/code&gt; World, &lt;code&gt;X06&lt;/code&gt; Africa, &lt;code&gt;X21&lt;/code&gt; Americas, &lt;code&gt;X84&lt;/code&gt; ASEAN) and 189 are ISO3-shaped. A real signal, since ISO 3166 leaves the X block user-assigned.&lt;/p&gt;

&lt;p&gt;Ask the same endpoint for &lt;code&gt;type=label&lt;/code&gt;, the friendlier parameter you reach for when you want a chart, and the codes vanish. "World" and "Afghanistan" come back as two strings of the same type. You destroyed the signal yourself, with a query parameter, and the response looks nicer for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the alarm stops working
&lt;/h2&gt;

&lt;p&gt;Population is additive, so a mistake shows up as a number that is 2x or 10x too big. Plenty of indicators are not additive, and there the same mistake produces nothing to see.&lt;/p&gt;

&lt;p&gt;The UK ONS beta API, regional GDP by year, chained volume measure, annual index, all industries, 2021. Twelve rows, one flat list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UKF  East Midlands                98.6
UKH  East of England              96.2
UKI  London                       96.4
UKJ  South East                   96.6
UKK  South West                   96.2
UKD  North West                   97.1
UKE  Yorkshire and The Humber     97.4
UK0  England                      96.6
UKL  Wales                        97
UKC  North East                   95.2
UKG  West Midlands                96.1
UKZ  Extra-regio                  81.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UK0&lt;/code&gt; is England. Nine of the other eleven rows are inside it. Every code is three characters starting with &lt;code&gt;UK&lt;/code&gt;, so nothing about the shape separates the parent from its children, and the response has no level field.&lt;/p&gt;

&lt;p&gt;Now take the average across those twelve, which is what a dashboard does. You get &lt;code&gt;95.3833&lt;/code&gt;. Drop England and average the remaining eleven: &lt;code&gt;95.2727&lt;/code&gt;. A tenth of an index point apart. There is no 10x, no 2x, no absurd figure to catch you. The number is simply a little wrong, forever, and no assertion you can write on magnitude alone will ever fire.&lt;/p&gt;

&lt;p&gt;The same dataset does it twice, by the way. The industry dimension has 23 codes including &lt;code&gt;A--T&lt;/code&gt; ("A-T: Total"), &lt;code&gt;B--E&lt;/code&gt; ("Production Industries") and &lt;code&gt;G--T&lt;/code&gt; ("Services sector") sitting beside the single letters they contain. The only tell is the double dash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: keep the total, and make it a test
&lt;/h2&gt;

&lt;p&gt;The reflex is to delete the aggregate rows and move on. Do the opposite. The total row is the only place in the response where the provider tells you what the answer should be, and throwing it away is throwing away your test data.&lt;/p&gt;

&lt;p&gt;Two rules. Allow-list, never deny-list, because an allow-list fails closed on an empty or unknown key. Then assert your members against the declared total.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable, standard library only, continues the script above
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;split_levels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Allow-list the members, name the totals, refuse anything unclassified.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iso2Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aggregates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;totals&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iso2Code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Aggregates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&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;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;# populated on every row; iso3 is not
&lt;/span&gt;        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows in neither list: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                         &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;country&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;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dropped&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_against_total&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tolerance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;members sum to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, total row says &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                             &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;drift&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) - the filter is wrong, not the data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt;

&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split_levels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_against_total&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;members kept   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totals dropped &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;members sum    &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WLD row says   &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;residual       &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;drift&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# a check that cannot fail is decoration: feed it the deny-list version
&lt;/span&gt;&lt;span class="n"&gt;sloppy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;countryiso3code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;AGG_ISO3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;check_against_total&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sloppy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;world&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;AssertionError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;deny-list version -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output, same run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;members kept   217
totals dropped 47
members sum    8,039,550,134
WLD row says   8,062,923,417
residual       -23,373,283  (-0.29%)

deny-list version -&amp;gt; members sum to 16,102,473,551, total row says 8,062,923,417 (+99.71%) - the filter is wrong, not the data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message is the payoff. A &lt;code&gt;+99.71%&lt;/code&gt; drift is not a judgement call, and it fires the moment your classification is wrong, in CI, before anyone charts it. An unclassified row raises a &lt;code&gt;ValueError&lt;/code&gt; instead, because &lt;code&gt;split_levels&lt;/code&gt; refuses to guess. Non-additive indicators need a different comparison than a sum, but the shape holds: the provider published the answer, so check against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The residual I could not explain
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-23,373,283&lt;/code&gt; people, or &lt;code&gt;-0.29%&lt;/code&gt;. I set the tolerance to 1% because I measured the gap first and then picked a threshold that passes it, which you should know before you copy the number.&lt;/p&gt;

&lt;p&gt;I do not know where it comes from. All 217 countries in the reference list are present in the 2023 response, so it is not a missing row. It might be territories the World Bank counts in the world total but not among its economies, or a vintage difference between the aggregate and its members.&lt;/p&gt;

&lt;p&gt;One thing I noticed and then refused to write down as a finding: Our World in Data puts Taiwan's 2023 population at &lt;code&gt;23,317,145&lt;/code&gt;, and the World Bank does not list Taiwan among its economies at all. Within 0.24% of my residual. That is the kind of coincidence that feels like an explanation, and nothing I called gives me evidence it is one. Two close numbers are not a mechanism. If you know the real answer, I want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I nearly published and had to take back
&lt;/h2&gt;

&lt;p&gt;While checking DBnomics I ran &lt;code&gt;country_labels.get("XD")&lt;/code&gt; and got nothing back. Same for &lt;code&gt;XM&lt;/code&gt;, &lt;code&gt;XN&lt;/code&gt;, &lt;code&gt;XT&lt;/code&gt;. I had a lovely paragraph half-written: the mirror ingested the World Bank feed, keyed on the code that was empty, and dropped exactly the four aggregates that break everyone else.&lt;/p&gt;

&lt;p&gt;It was wrong. I searched by label instead of by code and found &lt;code&gt;HIC&lt;/code&gt; High income, &lt;code&gt;LIC&lt;/code&gt; Low income, &lt;code&gt;LMC&lt;/code&gt; Lower middle income, &lt;code&gt;UMC&lt;/code&gt; Upper middle income, all present. DBnomics keys on the alpha-3 ids the World Bank publishes in its own catalogue, which is exactly the code the indicator payload declines to send. Nothing was dropped. My probe had returned an empty result that read like evidence, and the mirror turned out to be more faithful to the World Bank's catalogue than the World Bank's own data rows.&lt;/p&gt;

&lt;p&gt;That is the failure mode I keep meeting in this work, and it is not specific to statistics APIs: a lookup that returns nothing looks identical whether the thing is absent or your query was wrong. The only defence is to make the check prove it can find something before you trust it finding nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not the response-envelope post
&lt;/h2&gt;

&lt;p&gt;Two neighbours, so let me draw the lines by hand.&lt;/p&gt;

&lt;p&gt;I covered the World Bank once before, in a &lt;a href="https://blog.spinov.online/blog/free-government-apis-no-key/" rel="noopener noreferrer"&gt;roundup of keyless government APIs&lt;/a&gt;, where the trap was structural: the response is a two-element array, &lt;code&gt;[0]&lt;/code&gt; is pagination, &lt;code&gt;[1]&lt;/code&gt; is your data. That one is about unpacking. This post assumes you unpacked correctly and starts a step later, when the rows are in hand and you cannot tell what kind of thing each one is.&lt;/p&gt;

&lt;p&gt;Nor is it the scale problem from &lt;a href="https://blog.spinov.online/blog/free-currency-exchange-rate-apis-no-key/" rel="noopener noreferrer"&gt;keyless currency APIs&lt;/a&gt;, where everyone agreed on the entity and disagreed on how many digits it had. Here the scale is fine. What is contested is whether the row in front of you is a member or a summary of members. And the damage is extra rows, not missing ones, so it inflates rather than erases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is a snapshot.&lt;/strong&gt; All ten were keyless on July 26, 2026, from one machine, one IP. I did not read anyone's terms or measure their limits, which is why there is not a single rate-limit number in this post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One indicator per provider,&lt;/strong&gt; mostly population or GDP. The ratios belong to that indicator and year, not to the provider. &lt;code&gt;SP.POP.TOTL&lt;/code&gt; for 2023 produced 10.7240x; another indicator with different aggregate coverage lands somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Additive only.&lt;/strong&gt; The sum-versus-total check works because headcounts add. Rates and indices do not, and the ONS example is the alarm going quiet exactly there. I did not build the weighted-mean version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The four income groups matching the World row to the person is a property of that partition,&lt;/strong&gt; not a law. The regional aggregates in the same response overlap and partition nothing cleanly, which is why the naive sum is a ragged 10.72x.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I did not classify all 276 ILOSTAT areas by hand.&lt;/strong&gt; I counted X-prefixed codes and trusted the convention. If ILOSTAT ever gives a country an X code, my count is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance.&lt;/strong&gt; I run scrapers and data pipelines in production, 32 published actors and something over 2,000 runs, and enrichment feeds are where I picked up the habit behind this post: they lie with a confident 200 far more often than they fail honestly. The ten checks here are one sitting, not a production sample, and I am not going to dress them up as one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An aside, since I noticed it.&lt;/strong&gt; The ONS observation payload returns dimension hrefs pointing at &lt;code&gt;10.30.156.187:10500&lt;/code&gt;, a private address unreachable from the internet. Harmless here, but do not build a crawler that follows them.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The check I described works when the provider publishes the total. Eight of these ten publish a total I could actually see in the data I pulled. UNESCO I could not confirm from data rows: the aggregate &lt;code&gt;geoUnit&lt;/code&gt; request came back HTTP 200 with an empty &lt;code&gt;records&lt;/code&gt; array, which tells me nothing either way. And the ONS series I pulled has no total row at all: twelve rows, nine English regions plus England, Wales and Extra-regio, with no UK, no Scotland and no Northern Ireland. So "never delete the world row" is decent advice right up until there is no world row, and then you are back to trusting a sum nobody published.&lt;/p&gt;

&lt;p&gt;The overlapping case is where I run out of answers. In one World Bank response, &lt;code&gt;XD&lt;/code&gt;/&lt;code&gt;XM&lt;/code&gt;/&lt;code&gt;XN&lt;/code&gt;/&lt;code&gt;XT&lt;/code&gt; partition the planet exactly and the regional aggregates do not, because economies belong to several groupings at once. So "sum the members and compare" only works once you know which aggregate set is a partition, and the payload does not tell you that either. I hard-code the partition I trust per provider and treat the rest as decoration. It does not scale past the handful of feeds I actually use.&lt;/p&gt;

&lt;p&gt;If you pull cross-country statistics regularly: how do you decide which aggregate set is the partition, and do you keep that knowledge in code, in a config file, or in your head? 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance. Every status code, row count, sum and command output above comes from my own live requests and negative controls on July 26, 2026, printed as they came out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>data</category>
    </item>
    <item>
      <title>10 Free Art &amp; Museum APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Thu, 16 Jul 2026 10:17:30 +0000</pubDate>
      <link>https://dev.to/0012303/10-free-art-museum-apis-with-no-key-2026-3nm8</link>
      <guid>https://dev.to/0012303/10-free-art-museum-apis-with-no-key-2026-3nm8</guid>
      <description>&lt;p&gt;I asked the Metropolitan Museum's free API for Rembrandts. It said 182.&lt;/p&gt;

&lt;p&gt;I opened the first forty of those 182. Six were Rembrandt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: no key, no signup&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://collectionapi.metmuseum.org/public/collection/v1/search?q=Rembrandt&amp;amp;hasImages=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;182&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"objectIDs"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="mi"&gt;437398&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;437394&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;437396&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;436543&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;551786&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;729644&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="mi"&gt;435817&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;472562&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;438821&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;437769&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;329077&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;310453&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth ID in that list, 436543, is a Goya. The fifth, 551786, is an Egyptian Book of the Dead written between roughly 332 and 200 B.C. Both came back HTTP 200, valid JSON, counted inside a field named &lt;code&gt;total&lt;/code&gt;. The field is not broken. It is telling the exact truth about how many records matched the query. It is lying about what &lt;code&gt;total&lt;/code&gt; means to the person who typed &lt;code&gt;q=Rembrandt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea in this post
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;free art API&lt;/strong&gt; here means a public endpoint that returns museum collection data with no API key, no signup, no credit card. A URL you can paste into a terminal right now. Ten of them clear that bar, and I re-verified every response below with a live &lt;code&gt;curl&lt;/code&gt; on July 16, 2026: real HTTP code, real body, trimmed but never reworded. If you want the working list and nothing else, the table is two screens down.&lt;/p&gt;

&lt;p&gt;But the list is not why I wrote this. My last few keyless-API posts kept circling one room. A &lt;code&gt;null&lt;/code&gt; sails through a schema check. A full, correctly shaped result list hands you the wrong top row. A perfectly current-looking &lt;code&gt;timestamp&lt;/code&gt; sits on top of data that froze in 2024. This post moves the problem one more step, into the quietest corner of it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A field's name is a promise the 200 does not keep.&lt;/strong&gt; &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;hasImages&lt;/code&gt;, &lt;code&gt;isPublicDomain&lt;/code&gt;. Each returns HTTP 200 with a valid, plausible value. Each answers a narrower or different question than its name implies. And the record wearing that field is authoritative. It is the Met. It is Harvard. That is exactly why the label fools you: the source is real, so you stop reading the field and start trusting the word.&lt;/p&gt;

&lt;p&gt;Museums make this visible because their data is messy in an honest, well-documented way. A search index that matches provenance text. A boolean whose real subject is a rights office, not a copyright statute. A count that is secretly a floor. None of it is a bug. All of it will put wrong numbers on your dashboard while returning 200 the whole time.&lt;/p&gt;

&lt;p&gt;One scope note, so the numbers stay honest. I have run web scrapers in production: 2,190 runs across 32 published actors, 962 of them on one Trustpilot review scraper. That is where the habit in this post comes from, the reflex to read a field's value instead of its name, because the failures that actually hurt in production are never the crashes. But that is a scraping-and-enrichment domain. I have &lt;strong&gt;not&lt;/strong&gt; run a museum-collection resolver in production, and nothing below is load-bearing on those runs. Read them as the origin of a habit, not a claim about art data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10, at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it returns&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;The gotcha&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;The Met&lt;/td&gt;
&lt;td&gt;490k+ objects, full records&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET collectionapi.metmuseum.org/.../objects/436535&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A search miss returns &lt;code&gt;objectIDs: null&lt;/code&gt;, not &lt;code&gt;[]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Art Institute of Chicago&lt;/td&gt;
&lt;td&gt;Artworks, IIIF images&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.artic.edu/api/v1/artworks/27992&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;description&lt;/code&gt; field is CC-BY; the rest is CC0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Cleveland Museum of Art&lt;/td&gt;
&lt;td&gt;Open Access collection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET openaccess-api.clevelandart.org/api/artworks/92937&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Calls the rights field &lt;code&gt;share_license_status&lt;/code&gt;, not &lt;code&gt;isPublicDomain&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Victoria and Albert Museum&lt;/td&gt;
&lt;td&gt;1.4M+ objects&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.vam.ac.uk/v2/objects/search?q=painting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;record_count_exact&lt;/code&gt; flags whether the count is exact; it stayed &lt;code&gt;true&lt;/code&gt; even at 1.3M objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Yale LUX&lt;/td&gt;
&lt;td&gt;Yale collections, Linked Art&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET lux.collections.yale.edu/api/search-estimate/item?q=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The endpoint is named &lt;code&gt;search-estimate&lt;/code&gt;. The count is an estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;SMK (Denmark)&lt;/td&gt;
&lt;td&gt;Danish national gallery&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.smk.dk/api/v1/art/search/?keys=painting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;found&lt;/code&gt; counts records, many of which are not paintings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Wellcome Collection&lt;/td&gt;
&lt;td&gt;Medical/cultural works&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.wellcomecollection.org/catalogue/v2/works?query=painting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Top hit for "painting" is a photograph filed under Archives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Getty Vocabularies&lt;/td&gt;
&lt;td&gt;AAT / ULAN / TGN, Linked Data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET vocab.getty.edu/aat/300189808.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON-LD, not flat JSON. &lt;code&gt;_label&lt;/code&gt;, &lt;code&gt;broader&lt;/code&gt;, &lt;code&gt;equivalent&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Harvard LibraryCloud&lt;/td&gt;
&lt;td&gt;Harvard library metadata&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.lib.harvard.edu/v2/items.json?q=painting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;It is the library catalog, not the (key-gated) Art Museums&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Auckland War Memorial Museum&lt;/td&gt;
&lt;td&gt;Collection, Elasticsearch proxy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST api.aucklandmuseum.com/search/collectionsonline/_search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;total.value: 10000&lt;/code&gt; is a floor with &lt;code&gt;relation: "gte"&lt;/code&gt;, not a count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything in that table returned HTTP 200 with no key on July 16, 2026. Then, below the ten, the three that ask for a key even though every tutorial calls them free, and the one field that lies not about counting but about the law.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Met: the collection everything else gets compared to
&lt;/h2&gt;

&lt;p&gt;The Metropolitan Museum's Collection API is keyless, no signup, and it is the one most people reach for first. Fetch an object by ID and you get the whole record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://collectionapi.metmuseum.org/public/collection/v1/objects/436535"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"objectID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;436535&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"isPublicDomain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"primaryImage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://images.metmuseum.org/CRDImages/ep/original/DP-42549-001.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"department"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"European Paintings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"constituents"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Artist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Vincent van Gogh"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is Van Gogh's Wheat Field with Cypresses, and it behaves. Public domain, image present, artist named. The trouble starts at the search endpoint, and it has two edges worth knowing before you write a loop.&lt;/p&gt;

&lt;p&gt;First, a query that matches nothing does not return an empty list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://collectionapi.metmuseum.org/public/collection/v1/search?q=zzzxxqqnope123"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"objectIDs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;objectIDs&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, not &lt;code&gt;[]&lt;/code&gt;. Call &lt;code&gt;len()&lt;/code&gt; on that and your loop dies at 3am. Second, an object ID that does not exist returns a 404 with a message body, so &lt;code&gt;resp.json()&lt;/code&gt; succeeds and your happy-path code walks right into &lt;code&gt;{"message":"ObjectID not found"}&lt;/code&gt;. Guard for both. And be gentle: there is no key, but the Met asks for polite pacing, so send a real &lt;code&gt;User-Agent&lt;/code&gt; and cache what you can. Docs live at &lt;a href="https://metmuseum.github.io/" rel="noopener noreferrer"&gt;metmuseum.github.io&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Art Institute of Chicago: clean JSON with a licensing seam
&lt;/h2&gt;

&lt;p&gt;The Art Institute's API is one of the pleasant ones to build on. Simple GET, sensible fields, IIIF image server. It also does something the others do not: it tells you, in the response, that two parts of the same payload carry two different licenses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.artic.edu/api/v1/artworks/27992?fields=id,title,is_public_domain"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;27992&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"is_public_domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"license_text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"The `description` field ... is licensed under a Creative Commons Attribution 4.0 Generic License (CC-By) ... All other data ... is licensed under a Creative Commons Zero (CC0) 1.0 designation ..."&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is Seurat's A Sunday on La Grande Jatte. Note the split: the &lt;code&gt;description&lt;/code&gt; field is CC-BY, meaning if you show it you owe attribution, while the rest is CC0 and you owe nothing. Most people paste the whole record onto a page and quietly violate half of it. The API told you. Nobody reads &lt;code&gt;info.license_text&lt;/code&gt;. Full docs: &lt;a href="https://api.artic.edu/docs/" rel="noopener noreferrer"&gt;api.artic.edu/docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cleveland Museum of Art: the same question, a different field name
&lt;/h2&gt;

&lt;p&gt;Cleveland's Open Access API is CC0-friendly and returns rich records. It is also exhibit A for the whole thesis, because it asks the exact question the Met's &lt;code&gt;isPublicDomain&lt;/code&gt; asks, and names the field something else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://openaccess-api.clevelandart.org/api/artworks/92937"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;92937&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"accession_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1922.1133"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"share_license_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"CC0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Stag at Sharkey's"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Painting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"creation_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1909"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"department"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"American Painting and Sculpture"&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;share_license_status: "CC0"&lt;/code&gt;. Not a boolean called &lt;code&gt;isPublicDomain&lt;/code&gt;. A string describing the sharing license. Cleveland is being more honest about what the field is: a statement about &lt;em&gt;the image's reuse license&lt;/em&gt;, not about the copyright age of a George Bellows painting from 1909. Hold that distinction. It is the whole point of the isPublicDomain section further down. Docs: &lt;a href="https://openaccess-api.clevelandart.org/" rel="noopener noreferrer"&gt;openaccess-api.clevelandart.org&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Victoria and Albert Museum: a count that admits when it is not exact
&lt;/h2&gt;

&lt;p&gt;The V&amp;amp;A serves over a million objects, keyless. Search returns a compact record plus an &lt;code&gt;info&lt;/code&gt; block, and that block has a field I wish more APIs copied.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.vam.ac.uk/v2/objects/search?q=painting&amp;amp;page_size=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"record_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;146517&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"record_count_exact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9970&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"image_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;207381&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"records"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"systemNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"O429002"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"objectType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Painting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"_primaryTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Krishna painting Radha"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"_primaryDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1948"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"_primaryPlace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Sri Lanka"&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;146,517 objects matched "painting." See &lt;code&gt;record_count_exact: true&lt;/code&gt;. That flag is the whole point: the V&amp;amp;A ships a boolean whose only job is to tell you whether &lt;code&gt;record_count&lt;/code&gt; is an exact total or a search-engine estimate. I hammered it with broad queries on July 16, up to the full 1.3-million-object catalog, and it came back &lt;code&gt;true&lt;/code&gt; every time. I could not make it flip. But it is named and typed to carry that answer for the day the count is not exact, so you read the flag instead of guessing. That is the opposite of the trap this post is about. Compare that to number 5. Docs: &lt;a href="https://developers.vam.ac.uk/" rel="noopener noreferrer"&gt;developers.vam.ac.uk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Yale LUX: the count that says "estimate" right in the URL
&lt;/h2&gt;

&lt;p&gt;Yale's LUX is a single search layer over the university's museums, library, and archives, built on Linked Art. Keyless. It is heavier to parse than the flat-JSON museums, because it returns JSON-LD, and that is the price of the linked-data tier. It also names its counting endpoint with unusual honesty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only (-g stops curl from globbing the JSON braces into a 400)&lt;/span&gt;
curl &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://lux.collections.yale.edu/api/search-estimate/item?q={"text":"painting"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://linked.art/ns/v1/search.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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"OrderedCollection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"totalItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;76574&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"first"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://lux.collections.yale.edu/api/search/item?q=...&amp;amp;page=1&amp;amp;pageLength=20"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field is called &lt;code&gt;totalItems&lt;/code&gt;. The endpoint is called &lt;code&gt;search-estimate&lt;/code&gt;. Read those two facts together and LUX has already told you not to trust &lt;code&gt;totalItems&lt;/code&gt; as an exact number: it is a fast estimate, which is why there is a whole separate endpoint for it. This is the good version of the failure mode. The name of the route warns you about the meaning of the field. Most APIs do not extend you that courtesy. Docs and the Linked Art model: &lt;a href="https://lux.collections.yale.edu/" rel="noopener noreferrer"&gt;lux.collections.yale.edu&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. SMK: Denmark's national gallery, and a &lt;code&gt;found&lt;/code&gt; that counts loosely
&lt;/h2&gt;

&lt;p&gt;SMK, Statens Museum for Kunst, publishes a clean keyless search over the Danish national collection, much of it CC0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.smk.dk/api/v1/art/search/?keys=painting&amp;amp;rows=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rows"&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="nl"&gt;"found"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;253&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1170063760_object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2025-07-11T17:55:43Z"&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;found: 253&lt;/code&gt; for &lt;code&gt;keys=painting&lt;/code&gt;. The first item back is not a painting at all: it is a drawing from the print and drawing collection whose record text happens to contain the phrase "Fundamental Painting." Same disease as the Met's Rembrandt count. &lt;code&gt;keys&lt;/code&gt; is a full-text search over the record, and &lt;code&gt;found&lt;/code&gt; counts every record where the word turns up, in a title, an inscription, a note. It does not count paintings. It counts the string. Note the &lt;code&gt;modified&lt;/code&gt; field, though: SMK stamps every record with when it last changed, which is the honest half of this API. Docs: &lt;a href="https://www.smk.dk/en/article/smk-api/" rel="noopener noreferrer"&gt;smk.dk/en/article/smk-api&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Wellcome Collection: search "painting", get a photograph
&lt;/h2&gt;

&lt;p&gt;Wellcome holds art, books, archives, and manuscripts about health and human experience. Its catalogue API is keyless and well-built. It also shows you, in one call, why a museum search box is not a filing cabinet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wellcomecollection.org/catalogue/v2/works?pageSize=1&amp;amp;query=painting"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ResultList"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"totalResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;17584&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"sm62qtks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Painting Therapy; (people painting on lawn)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"workType"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Format"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Archives and manuscripts"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"physicalDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1 photograph"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top result for &lt;code&gt;query=painting&lt;/code&gt; is a photograph of a painting-therapy session, filed under Archives and manuscripts. It is a completely correct result: the record is about painting. If you assumed &lt;code&gt;query=painting&lt;/code&gt; returns paintings, you assumed the API indexes &lt;em&gt;subjects&lt;/em&gt; the way you think about them, and it indexes &lt;em&gt;text&lt;/em&gt; the way a search engine does. &lt;code&gt;totalResults: 17584&lt;/code&gt; is a text-match count, and its name does not warn you the way Yale's route name does. Docs: &lt;a href="https://developers.wellcomecollection.org/" rel="noopener noreferrer"&gt;developers.wellcomecollection.org&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Getty Vocabularies: not a collection, a dictionary of meanings
&lt;/h2&gt;

&lt;p&gt;The Getty publishes its vocabularies as Linked Open Data: the Art and Architecture Thesaurus (AAT), the Union List of Artist Names (ULAN), and the Getty Thesaurus of Geographic Names (TGN). Keyless. This is not artworks. It is the controlled vocabulary the art world uses to &lt;em&gt;describe&lt;/em&gt; artworks, and it is how you turn a free-text mess into linked entities.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://vocab.getty.edu/aat/300189808.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://linked.art/ns/v1/linked-art.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;"_label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"figures (representations)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"equivalent"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"http://www.wikidata.org/entity/Q29527347"&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;Like Yale, this is JSON-LD, so budget for a heavier shape than the flat-JSON museums. What you get for the trouble is real: an &lt;code&gt;equivalent&lt;/code&gt; link straight to Wikidata, a &lt;code&gt;broader&lt;/code&gt; chain up the concept tree, stable URIs you can join on. If you ever need to reconcile "who is this artist" or "what is this technique" across two museums that spell things differently, this is the linked-data tier that makes it possible. Endpoint and model: &lt;a href="https://vocab.getty.edu/" rel="noopener noreferrer"&gt;vocab.getty.edu&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Harvard LibraryCloud: the Harvard art API that is not the Harvard art API
&lt;/h2&gt;

&lt;p&gt;This one is a naming trap before you even read a field. Harvard runs two things. The Harvard Art Museums API at &lt;code&gt;api.harvardartmuseums.org&lt;/code&gt; needs a key. It is not on this list, and I will get to it below. LibraryCloud, at &lt;code&gt;api.lib.harvard.edu&lt;/code&gt;, is keyless, and it is a different animal entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.lib.harvard.edu/v2/items.json?q=painting&amp;amp;limit=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"pagination"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"numFound"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;515781&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"limit"&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="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"mods"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"titleInfo"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"conservation of easel paintings"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"genre"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"#text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"bibliography"&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;515,781 hits for "painting," and the first one is a bibliography, a &lt;em&gt;book about&lt;/em&gt; the conservation of easel paintings, encoded in MODS library metadata. If you came here expecting Harvard's artworks, you are in the library catalog, and the two are easy to confuse because both live under a Harvard domain and both answer &lt;code&gt;q=painting&lt;/code&gt; with a 200. The keyless one is books-and-metadata. The artworks are behind the key. Docs: &lt;a href="https://wiki.harvard.edu/confluence/display/LibraryStaffDoc/LibraryCloud+APIs" rel="noopener noreferrer"&gt;Harvard LibraryCloud APIs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Auckland War Memorial Museum: a count that is really a floor
&lt;/h2&gt;

&lt;p&gt;Auckland exposes its collection through a keyless Elasticsearch proxy, so instead of a REST search you POST a query body. Get the index name right (&lt;code&gt;collectionsonline&lt;/code&gt;) and it answers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.aucklandmuseum.com/search/collectionsonline/_search"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"size":1,"query":{"match_all":{}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"relation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"gte"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"collectionsonline-2022-05-04-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;"_source"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ecrm:E21_Person"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"appellation"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"KVD?"&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;Look at &lt;code&gt;total&lt;/code&gt;. It is not a number. It is an object: &lt;code&gt;{"value":10000,"relation":"gte"}&lt;/code&gt;. That &lt;code&gt;gte&lt;/code&gt; means "greater than or equal to," so &lt;code&gt;value: 10000&lt;/code&gt; is Elasticsearch telling you it stopped counting at ten thousand, and the real total is &lt;em&gt;at least&lt;/em&gt; that. Read &lt;code&gt;total.value&lt;/code&gt; as a count and you will report exactly 10,000 objects for a collection that has far more. The name &lt;code&gt;value&lt;/code&gt;, sitting under &lt;code&gt;total&lt;/code&gt;, reads like an answer. It is a floor with a footnote in the next field. And the first hit is a Person record, not an object, because the index mixes entity types. Docs: &lt;a href="https://api.aucklandmuseum.com/apidoc" rel="noopener noreferrer"&gt;api.aucklandmuseum.com/apidoc&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that lies about the law: isPublicDomain
&lt;/h2&gt;

&lt;p&gt;Now the field I actually opened this post to talk about, because it is the one that can cost you money instead of just a wrong dashboard number.&lt;/p&gt;

&lt;p&gt;Here is the Met's own documentation, word for word, from &lt;a href="https://metmuseum.github.io/" rel="noopener noreferrer"&gt;metmuseum.github.io&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;isPublicDomain&lt;/code&gt; (boolean) When "true" indicates an artwork in the Public Domain&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that as a developer and you hear a legal claim about the artwork: this painting's copyright has expired, you are free to use it. That is what "in the Public Domain" means as English.&lt;/p&gt;

&lt;p&gt;That is not what the field tracks. Watch what happens across a single artist. Van Gogh died in 1890. Every painting he made is uncontroversially out of copyright, everywhere on Earth. I pulled 30 objects the Met attributes to "Vincent van Gogh" and checked the flag on each.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Van Gogh-attributed objects sampled:  30
isPublicDomain = true:                29   (all with an open image)
isPublicDomain = false:                1   (a printed book of his letters, no image)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-nine true, one false. The false one is a printed edition of his letters that the Met has not released as an open image. So the flag did not flip because someone re-adjudicated Van Gogh's copyright between his paintings and his letters. It flipped because the Met has an open, CC0-licensed photograph of the paintings and does not have one of that book. The field's real subject is the Met's &lt;em&gt;image&lt;/em&gt;, not the artwork's &lt;em&gt;copyright&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Object 310453 makes it sharp. It came back in that Rembrandt search from the top of the post.&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="nl"&gt;"objectID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;310453&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Nkishi (community power figure)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"artistDisplayName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Songye artist and nganga (ritual specialist)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"department"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"The Michael C. Rockefeller Wing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"isPublicDomain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"primaryImage"&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isPublicDomain: false&lt;/code&gt;. I want to be precise, because this is where developers overclaim in both directions. This does &lt;strong&gt;not&lt;/strong&gt; mean the object is under copyright. A Songye community power figure made in the 19th or early 20th century may well be in the public domain as a matter of law. The &lt;code&gt;false&lt;/code&gt; here is not a court ruling. It means the Met has not released an open-access image of it, which the empty &lt;code&gt;primaryImage&lt;/code&gt; confirms in the same breath. The field answers "did the museum openly license its photo of this thing?" Its name asks "is this artwork's copyright expired?" Those are different questions, and only one of them has a clean answer in the JSON.&lt;/p&gt;

&lt;p&gt;Now line up all three museums on the same real question, which is "may I reuse this image without owing anyone anything?":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Met: &lt;code&gt;isPublicDomain&lt;/code&gt; (boolean)&lt;/li&gt;
&lt;li&gt;Art Institute of Chicago: &lt;code&gt;is_public_domain&lt;/code&gt; (boolean)&lt;/li&gt;
&lt;li&gt;Cleveland: &lt;code&gt;share_license_status&lt;/code&gt; (string, e.g. &lt;code&gt;"CC0"&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three field names, one underlying decision, made separately by three rights offices, with no shared canonical answer. If your app treats &lt;code&gt;isPublicDomain: true&lt;/code&gt; as "safe to reuse commercially," you are trusting each museum's image-licensing office and calling it copyright law. Usually you are fine. The day you are not, the field name is what talked you into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: read the value, not the name
&lt;/h2&gt;

&lt;p&gt;The pattern is the same every time. Do not ask the field what it is called. Ask the object what is actually true. Here is a resolver that does the thing the Met's &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;hasImages&lt;/code&gt;, and &lt;code&gt;isPublicDomain&lt;/code&gt; only pretend to do: it verifies each object actually matches the artist you asked for, and actually carries a usable open image, from the values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local: pip install requests
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;MET&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://collectionapi.metmuseum.org/public/collection/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field-meaning-demo/1.0 (contact you@example.com)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;usable_open_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The question a dev MEANS when reading isPublicDomain and hasImages:
    can I show an openly-licensed image of this object right now?
    Answer from the values, never from the field names.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primaryImage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isPublicDomain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;real_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;S&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hasImages&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;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;reported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# read the field named "total", not len()
&lt;/span&gt;    &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;objectIDs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;               &lt;span class="c1"&gt;# a miss returns null, not []
&lt;/span&gt;    &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;S&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/objects/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;artist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artistDisplayName&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="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;usable_open_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;

&lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sampled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;real_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rembrandt&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;Rembrandt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; the API reports:      &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;objects I sampled:            &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sampled&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actually Rembrandt + usable:  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output, July 16, 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'total' the API reports:      182
objects I sampled:            40
actually Rembrandt + usable:  6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six out of the first forty. The 34 it drops include the Goya from the top of the post, the Egyptian Book of the Dead, a Byzantine reliquary, and object 310453, the one whose &lt;code&gt;hasImages=true&lt;/code&gt; filter let it through with an empty &lt;code&gt;primaryImage&lt;/code&gt;. Two things about this code, said plainly so you do not oversell it.&lt;/p&gt;

&lt;p&gt;It only samples the first 40 IDs, because fetching all 182 objects one at a time is slow and rude to a keyless server. That is a real limitation: the true count of usable Rembrandts is somewhere in the collection, and this tells you the &lt;em&gt;rate&lt;/em&gt; in the first page, not the final number. If you need the number, page politely and cache. And the &lt;code&gt;want.lower() in artist.lower()&lt;/code&gt; check is deliberately loose. It keeps "Rembrandt (workshop)" and "Rembrandt van Rijn," which may or may not be what you want. The point is not that this exact filter is right for your app. The point is that it reads &lt;code&gt;artistDisplayName&lt;/code&gt; and &lt;code&gt;primaryImage&lt;/code&gt;, the fields whose values are true, instead of trusting &lt;code&gt;total&lt;/code&gt; and &lt;code&gt;hasImages&lt;/code&gt;, the fields whose names are convenient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flagged: three "free" APIs that still want a key
&lt;/h2&gt;

&lt;p&gt;Every listicle, mine included in spirit, calls these free. They are free. They are not keyless, and the difference is a 401 or 403 the moment you forget the key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://api.harvardartmuseums.org/object?size=1"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://api.si.edu/openaccess/api/v1.0/search?q=painting"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://api.europeana.eu/record/v2/search.json?query=painting"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;401
403
401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;Harvard Art Museums&lt;/strong&gt; API (401) is the one people confuse with keyless LibraryCloud from number 9. &lt;strong&gt;Smithsonian Open Access&lt;/strong&gt; (403) returns &lt;code&gt;API_KEY_MISSING&lt;/code&gt; and hands out a free key at api.data.gov. &lt;strong&gt;Europeana&lt;/strong&gt; (401) aggregates thousands of European institutions and is worth the signup, but a signup it is. Free-as-in-beer, not free-as-in-paste-into-a-terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not on the list: two that tutorials still recommend
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Rijksmuseum&lt;/strong&gt; API, still the top result in a lot of old blog posts, is gone:&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="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://www.rijksmuseum.nl/api/en/collection"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;410
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 410 Gone, which is a server saying "not moved, not temporarily down, deliberately removed." The legacy endpoint was decommissioned; the Rijksmuseum moved to a newer data service. Do not build on the 410. And the &lt;strong&gt;Brooklyn Museum&lt;/strong&gt; API returns a 429 with a "Vercel Security Checkpoint" body from a datacenter IP, a bot challenge rather than a real rate limit, so it is unreliable to depend on from a server even though a browser may sail through. (One I will not badmouth: the Science Museum Group returned a clean 200 for me today, though I have seen reports of it blocking datacenter IPs, so test from your own host before you trust it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  So what do you actually do?
&lt;/h2&gt;

&lt;p&gt;Two habits, and they are cheap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the field's definition, not its name.&lt;/strong&gt; Before you branch on &lt;code&gt;isPublicDomain&lt;/code&gt;, &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;hasImages&lt;/code&gt;, or &lt;code&gt;found&lt;/code&gt;, open the docs and find out what the museum means by it. Ten minutes with &lt;a href="https://metmuseum.github.io/" rel="noopener noreferrer"&gt;metmuseum.github.io&lt;/a&gt; tells you &lt;code&gt;isPublicDomain&lt;/code&gt; is tied to the open-access image release. Ten minutes with the V&amp;amp;A docs tells you &lt;code&gt;record_count_exact&lt;/code&gt; exists. The good APIs in this post, the V&amp;amp;A with its exactness flag, Yale with its &lt;code&gt;search-estimate&lt;/code&gt; route, SMK with its &lt;code&gt;modified&lt;/code&gt; stamp, are the ones that put the caveat in a field so you can read it. Reward that by actually reading it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify the object, not the count.&lt;/strong&gt; A count is a promise about a set you have not looked at. When the answer matters, fetch a sample and check that the objects are what the count says they are, from their own values. Thirty lines of &lt;code&gt;real_matches&lt;/code&gt; turned "182 Rembrandts" into "6 of the first 40," and the gap between those two numbers is the gap between a demo and a bug report.&lt;/p&gt;

&lt;p&gt;I learned the expensive half of this in a different domain. Across 2,190 production scraper runs, the incidents that actually cost me were never the 500s. A crash wakes you up and you fix it by lunch. The response that comes back 200, valid, non-empty, correctly shaped, and quietly answering a different question than you asked, that one sits in a column until a report looks wrong three weeks later, and then you are backfilling. Museum APIs just make it pretty. Your API is probably doing the same thing with a blander field name. Go read what the name actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the best free art or museum API with no key?&lt;/strong&gt;&lt;br&gt;
It depends on the job, and several are keyless together. The Met (&lt;code&gt;collectionapi.metmuseum.org&lt;/code&gt;) has the broadest collection and the simplest flat JSON. The Art Institute of Chicago (&lt;code&gt;api.artic.edu&lt;/code&gt;) is the cleanest to build on and ships IIIF images. Cleveland (&lt;code&gt;openaccess-api.clevelandart.org&lt;/code&gt;) and SMK (&lt;code&gt;api.smk.dk&lt;/code&gt;) are CC0-friendly. The V&amp;amp;A (&lt;code&gt;api.vam.ac.uk&lt;/code&gt;) covers over a million objects. All returned HTTP 200 with no key on July 16, 2026. Whichever you pick, read what its count and rights fields actually mean before you trust them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the Met Museum API require an API key?&lt;/strong&gt;&lt;br&gt;
No. The Metropolitan Museum Collection API is fully keyless: no signup, no key, no token. It asks only for polite request pacing and a real &lt;code&gt;User-Agent&lt;/code&gt;. Two traps to guard for: a search that matches nothing returns &lt;code&gt;{"total":0,"objectIDs":null}&lt;/code&gt; with &lt;code&gt;objectIDs&lt;/code&gt; as &lt;code&gt;null&lt;/code&gt; rather than an empty array, and a request for a nonexistent object ID returns HTTP 404 with &lt;code&gt;{"message":"ObjectID not found"}&lt;/code&gt; rather than an empty body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does isPublicDomain mean in the Met API?&lt;/strong&gt;&lt;br&gt;
The Met documents it as "when true, indicates an artwork in the Public Domain," but in practice the flag tracks whether the Met has released an open-access (CC0) image of the object, not a legal ruling on the artwork's copyright. Evidence: of 30 objects attributed to Van Gogh (died 1890, unquestionably out of copyright), 29 returned &lt;code&gt;isPublicDomain: true&lt;/code&gt; and all carried an open image, while the one &lt;code&gt;false&lt;/code&gt; was a printed book with no released image. So &lt;code&gt;isPublicDomain: false&lt;/code&gt; does not mean the work is copyrighted; it can mean the Met simply has no open image of it, which the empty &lt;code&gt;primaryImage&lt;/code&gt; confirms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a museum search return objects that do not match my query?&lt;/strong&gt;&lt;br&gt;
Because museum search is full-text over the whole record, not a filter on a subject field. The Met's &lt;code&gt;search?q=Rembrandt&amp;amp;hasImages=true&lt;/code&gt; returned &lt;code&gt;total: 182&lt;/code&gt;, but the keyword also matched text elsewhere in the catalog record, not the artist field, so a Goya (object 436543) and an Egyptian Book of the Dead (object 551786) are counted among the 182. In a sample of the first 40 objects, only 6 were actually Rembrandt with a usable open image. Verify each object's &lt;code&gt;artistDisplayName&lt;/code&gt; rather than trusting the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which museum APIs need an API key?&lt;/strong&gt;&lt;br&gt;
On July 16, 2026, the Harvard Art Museums API (&lt;code&gt;api.harvardartmuseums.org&lt;/code&gt;) returned HTTP 401, the Smithsonian Open Access API (&lt;code&gt;api.si.edu&lt;/code&gt;) returned HTTP 403 with &lt;code&gt;API_KEY_MISSING&lt;/code&gt;, and Europeana (&lt;code&gt;api.europeana.eu&lt;/code&gt;) returned HTTP 401. All three offer a free key on signup, so they are free but not keyless. Note that the keyless Harvard endpoint on this list, LibraryCloud at &lt;code&gt;api.lib.harvard.edu&lt;/code&gt;, is a different service (library metadata) from the key-gated Harvard Art Museums.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use museum images commercially for free?&lt;/strong&gt;&lt;br&gt;
Sometimes, and the field that says so answers a narrower question than its name. Fields like the Met's &lt;code&gt;isPublicDomain&lt;/code&gt;, the Art Institute's &lt;code&gt;is_public_domain&lt;/code&gt;, and Cleveland's &lt;code&gt;share_license_status&lt;/code&gt; describe whether the &lt;em&gt;museum released its image&lt;/em&gt; under an open license (often CC0), not whether the underlying artwork's copyright has expired. Also watch for split licensing: the Art Institute's &lt;code&gt;info.license_text&lt;/code&gt; states that its &lt;code&gt;description&lt;/code&gt; field is CC-BY (attribution required) while the rest of the record is CC0. Check the specific field per museum before reusing anything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every endpoint above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 16, 2026 before publishing; responses are trimmed, never reworded. I have not run a museum-collection resolver in production; those runs are a scraping-and-enrichment domain, cited only as the origin of the read-the-value habit. I wrote up neighboring versions of this same pattern for &lt;a href="https://blog.spinov.online/blog/free-space-astronomy-apis-no-key/" rel="noopener noreferrer"&gt;free space and astronomy APIs&lt;/a&gt;, &lt;a href="https://blog.spinov.online/blog/free-fun-facts-apis-no-key/" rel="noopener noreferrer"&gt;free fun-facts APIs&lt;/a&gt;, and &lt;a href="https://blog.spinov.online/blog/free-mock-fake-data-apis-no-key/" rel="noopener noreferrer"&gt;free mock and fake-data APIs&lt;/a&gt;. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless layer I verify. And tell me: which field name on your favorite API secretly answers a different question than its name promises? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>11 Free Space &amp; Astronomy APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:07:59 +0000</pubDate>
      <link>https://dev.to/0012303/11-free-space-astronomy-apis-with-no-key-2026-1mde</link>
      <guid>https://dev.to/0012303/11-free-space-astronomy-apis-with-no-key-2026-1mde</guid>
      <description>&lt;p&gt;On July 13, 2026 I asked two free ISS-tracking APIs where the space station was. Fourteen seconds apart. Both answered HTTP 200 with clean, valid JSON.&lt;/p&gt;

&lt;p&gt;They were 9,303 km apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: no key needed for either&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://api.open-notify.org/iss-now.json"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wheretheiss.at/v1/satellites/25544"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"iss_position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-25.2996"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"154.1942"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1783969008&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;25544&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;37.5181&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;95.3130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"altitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;429.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"velocity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;27566.63&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"visibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"daylight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1783969022&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The station moves at 27,566 km/h. That is 7.66 km/s, and the second API tells you so in its own body. Its ground track is &lt;em&gt;slower&lt;/em&gt; than that, not faster, and two separate effects stack up to make it so. The station flies 430 km up, so the point underneath it sweeps the same angle across a smaller radius: that alone drops it to about 7.2 km/s, before anything else happens. Then the orbit is prograde, and the planet turns the same way underneath it, which shaves off a bit more. I got this backwards in my first draft, so I stopped reasoning and measured instead. Two &lt;code&gt;wheretheiss&lt;/code&gt; samples 64 seconds apart put the ground track 439.9 km further along: &lt;strong&gt;6.87 km/s&lt;/strong&gt;. JPL's own ephemeris, two rows 19 seconds apart, says 6.86.&lt;/p&gt;

&lt;p&gt;So round up hard. Give it a flat 8 km/s, more than any of those numbers, and fourteen seconds buys you at most 112 km of great-circle distance. Not 9,303.&lt;/p&gt;

&lt;p&gt;So one of these is lying, at HTTP 200, with &lt;code&gt;"message": "success"&lt;/code&gt; written right there in the payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea in this post
&lt;/h2&gt;

&lt;p&gt;My previous keyless-API posts kept walking around the same room. A 200 can carry an empty body. A 201 Created can be followed by a 404 on read-back. A 200 can parse, match your schema, and hand you &lt;code&gt;null&lt;/code&gt;. A 200 can return a full, correctly shaped list whose top row is the wrong entity. This post moves the problem again, and it is the version that scares me most, because it is the one you can run for a year without noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 200 with a valid, well-shaped, non-empty, plausible-looking body is not a 200 with current data.&lt;/strong&gt; Freshness is not in the status line. It is a field inside the body: &lt;code&gt;EPOCH&lt;/code&gt;, &lt;code&gt;time_tag&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;lastRun&lt;/code&gt;, &lt;code&gt;updated&lt;/code&gt;. A server that stopped updating in 2024 will keep telling you, cheerfully and successfully, that 2024 is right now.&lt;/p&gt;

&lt;p&gt;Space is where this shows up naked, because space data is time-series by nature. A stale satellite position looks exactly like a satellite position. Nothing in &lt;code&gt;resp.ok&lt;/code&gt; will ever tell you the difference.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free space API&lt;/strong&gt; here means a public endpoint returning space or astronomy data with no API key, no signup, no credit card. A URL you can paste into a terminal right now. Eleven clear that bar. I re-verified every response below with a live &lt;code&gt;curl&lt;/code&gt; on July 13, 2026: real HTTP code, real body, trimmed but never reworded.&lt;/p&gt;

&lt;p&gt;One scope note, so the numbers stay honest. I curl-verified all eleven APIs on July 13, 2026. I have &lt;strong&gt;not&lt;/strong&gt; run a satellite-tracking service in production. My 2,190 production scraper runs (962 of them on one Trustpilot scraper) are a different domain. I cite them for exactly one reason: they are why I read a body's own timestamp instead of its status line. Do not read 2,190 as a claim about these eleven endpoints.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it returns&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;The gotcha&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Where the ISS at&lt;/td&gt;
&lt;td&gt;Live ISS lat/lon/alt/velocity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.wheretheiss.at/v1/satellites/25544&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;timestamp&lt;/code&gt; is when you asked, not when it was measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CelesTrak GP&lt;/td&gt;
&lt;td&gt;Orbital elements (OMM/TLE)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;amp;FORMAT=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A miss is a 404 with plain text, not JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;TLE API&lt;/td&gt;
&lt;td&gt;Two-line elements by NORAD ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET tle.ivanstanojevic.me/api/tle/25544&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same satellite, twice the epoch age of CelesTrak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;JPL SSD/CNEOS&lt;/td&gt;
&lt;td&gt;Asteroids, close approaches, fireballs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET ssd-api.jpl.nasa.gov/sbdb.api?sstr=433&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Errors ride inside a 200 body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;JPL Horizons&lt;/td&gt;
&lt;td&gt;Ephemerides for any solar-system body&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET ssd.jpl.nasa.gov/api/horizons.api?format=json&amp;amp;...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;format=json&lt;/code&gt; wraps a plain-text blob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;NASA Exoplanet Archive&lt;/td&gt;
&lt;td&gt;6,319 planets in 39,978 rows, queryable by ADQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET exoplanetarchive.ipac.caltech.edu/TAP/sync?query=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A bad query returns XML, whatever format you asked for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;NASA Images&lt;/td&gt;
&lt;td&gt;1,510 Apollo 11 images, and the rest&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET images-api.nasa.gov/search?q=apollo 11&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asset URLs need a second fetch, and they are &lt;code&gt;http://&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;NOAA SWPC&lt;/td&gt;
&lt;td&gt;Planetary K-index (geomagnetic storms)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET services.swpc.noaa.gov/products/noaa-planetary-k-index.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;time_tag&lt;/code&gt; opens a 3-hour bin, so "now" is never now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Launch Library 2&lt;/td&gt;
&lt;td&gt;364 upcoming launches, crew, agencies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET ll.thespacedevs.com/2.2.0/launch/upcoming/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anonymous calls are rate-limited, hard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;SatNOGS DB&lt;/td&gt;
&lt;td&gt;Open satellite + transmitter catalog&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET db.satnogs.org/api/satellites/?format=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Records carry an &lt;code&gt;updated&lt;/code&gt; field for a reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;SIMBAD TAP&lt;/td&gt;
&lt;td&gt;Astronomical object database (CDS)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST simbad.cds.unistra.fr/simbad/sim-tap/sync&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Andromeda is not typed "Galaxy"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then a flagged near-miss, three APIs that used to be keyless and are not, and the gate that catches the frozen ones. Along the way I get an arbiter badly wrong and have to throw it out, which turned out to be the most useful hour of the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Where the ISS at: the one that was right, and could not have told you
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;api.wheretheiss.at&lt;/code&gt; gives you the station's position, altitude, velocity, and footprint, keyless, no signup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wheretheiss.at/v1/satellites/25544"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;25544&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;37.5181&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;95.3130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"altitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;429.73995965565&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"velocity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;27566.631304384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"visibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"daylight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"footprint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;4556.6877316229&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1783969022&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"units"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"kilometers"&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;This one was correct. I checked, and I will show you how, and I will also show you the check I got wrong on the way there. But look at what its &lt;code&gt;timestamp&lt;/code&gt; actually is: 1783969022 is the moment I sent the request. Not the moment anything was measured. It is a propagator: it takes a set of orbital elements and computes where the station is now, then stamps the answer with now.&lt;/p&gt;

&lt;p&gt;That is the trap in miniature. The field named &lt;code&gt;timestamp&lt;/code&gt; looks like a freshness signal and is not one. If the elements underneath went stale, this API would keep stamping fresh times on drifting positions, and the field called &lt;code&gt;timestamp&lt;/code&gt; would never once flinch.&lt;/p&gt;

&lt;p&gt;But here is the part I missed on my first pass, and it is the most useful thing in this section: &lt;code&gt;wheretheiss&lt;/code&gt; will show you the elements it is propagating. You have to ask a different endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wheretheiss.at/v1/satellites/25544/tles"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"requested_timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1783971050&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tle_timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1783883409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"25544"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"header"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ISS (ZARYA)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"line1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1 25544U 98067A   26193.79871965  .00004831  00000+0  95802-4 0  9996"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"line2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2 25544  51.6301 173.3406 0006695 288.1916  71.8344 15.48994114575722"&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;tle_timestamp&lt;/code&gt; is the epoch of the element set underneath the answer. Subtract it from &lt;code&gt;requested_timestamp&lt;/code&gt; and you get the number that actually matters: when I pulled this, the elements were 24.3 hours old. That is the field to gate on. Not the one named &lt;code&gt;timestamp&lt;/code&gt;, which is just a clock. Keep those two lines of TLE in mind. They come back to embarrass me in a few sections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; a live ISS map or an "is it overhead" check. Gate on &lt;code&gt;/tles&lt;/code&gt;, not on &lt;code&gt;timestamp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs:&lt;/strong&gt; &lt;code&gt;wheretheiss.at/w/developer&lt;/code&gt;. I have left that unlinked on purpose, and the reason is too good to bury. The docs host's TLS certificate expired at 14:02:56 UTC on July 12, 2026, one day before I pulled all of this, so &lt;code&gt;curl&lt;/code&gt; refuses it with exit 60 and your browser will throw a full-page warning. The certificate on the API host next door is fine until July 19. The endpoint works. The page telling you how to use the endpoint does not. Check it yourself before you click, because by the time you read this it has probably been renewed, which is the other half of the lesson: everything in this post has an expiry date, including this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. CelesTrak: the orbital elements everything else is built from
&lt;/h2&gt;

&lt;p&gt;CelesTrak is Dr. T.S. Kelso's satellite element catalog, and it is where most people get their elements. It is not where the elements come from. That distinction matters more than it sounds: the tracking data is produced by the US Space Force's 18th Space Defense Squadron and published through Space-Track, and CelesTrak curates it, formats it, and hands it to you without a login. When you build on CelesTrak you are depending on two organizations, not one. Keyless. It serves classic TLE, and OMM in JSON, which is nicer to parse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;amp;FORMAT=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"OBJECT_NAME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ISS (ZARYA)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"OBJECT_ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1998-067A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"EPOCH"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-13T07:33:22.422240"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"MEAN_MOTION"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;15.48997295&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ECCENTRICITY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0006687&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"INCLINATION"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;51.6305&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"NORAD_CAT_ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;25544&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"REV_AT_EPOCH"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;57580&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"BSTAR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.1266e-5&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;Note &lt;code&gt;EPOCH&lt;/code&gt;. It is not the timestamp of an observation. It is the reference time the element set is valid for: the anchor of a fit made against days of accumulated tracking, not the moment somebody pointed a radar at the thing. When I pulled this it was 11.4 hours in the past. Normal, fine, and honest: the API is telling you, unprompted, how old its own truth is. Remember that field. It is the one that saves you later.&lt;/p&gt;

&lt;p&gt;The gotcha is at the other end. Ask for a satellite that does not exist and you do not get JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;" [HTTP %{http_code}]&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://celestrak.org/NORAD/elements/gp.php?CATNR=99999&amp;amp;FORMAT=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No GP data found [HTTP 404]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain text, HTTP 404, despite &lt;code&gt;FORMAT=json&lt;/code&gt; in the query. So &lt;code&gt;resp.json()&lt;/code&gt; raises a decode error, not a clean empty list, and your error handling has to catch both. Also: CelesTrak asks you not to hammer it, so cache the elements and propagate locally instead of re-fetching every second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; anything that needs real orbital elements. Docs: &lt;a href="https://celestrak.org/NORAD/documentation/gp-data-formats.php" rel="noopener noreferrer"&gt;celestrak.org/NORAD/documentation/gp-data-formats.php&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. TLE API: the same satellite, twice as stale
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tle.ivanstanojevic.me&lt;/code&gt; is a friendly JSON wrapper over TLE data, keyless, no rate-limit drama.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://tle.ivanstanojevic.me/api/tle/25544"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Tle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"satelliteId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;25544&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ISS (ZARYA)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-12T19:10:09+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"line1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1 25544U 98067A   26193.79871965  .00004831  00000+0  95802-4 0  9996"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"line2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2 25544  51.6301 173.3406 0006695 288.1916  71.8344 15.48994114575722"&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;Now put it next to CelesTrak. Same satellite, same minute, both keyless, both HTTP 200, both returning a perfectly valid TLE:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feed&lt;/th&gt;
&lt;th&gt;Epoch field&lt;/th&gt;
&lt;th&gt;Age when I pulled it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CelesTrak&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EPOCH: 2026-07-13T07:33:22&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;11.4 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLE API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;date: 2026-07-12T19:10:09&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;23.7 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twice the age. Neither response is wrong. Neither status code differs. And now the number, because "twice as stale" is the kind of phrase that sounds alarming until you measure it. I propagated both element sets to the same instant. They land &lt;strong&gt;1.0 km apart&lt;/strong&gt;, about 0.13 seconds of along-track lag.&lt;/p&gt;

&lt;p&gt;One kilometre. That is the entire cost of twelve extra hours of TLE age for this satellite, on this day. Do not carry that number anywhere else, and I want to be specific about why, because it is the kind of result that gets quoted out of its cage. Both feeds trace back to the same producer. What I measured is how &lt;em&gt;stable&lt;/em&gt; one organization's successive fits are, not how &lt;em&gt;accurate&lt;/em&gt; they are, and I measured it on a fat, well-tracked, high-drag-but-boring object during a quiet geomagnetic week. A cubesat with a bad area-to-mass ratio, a Starlink in the middle of a station-keeping burn, a Kp-7 storm puffing up the thermosphere, or a prediction horizon of a full day instead of twelve hours will each give you a much bigger number. Read the &lt;code&gt;date&lt;/code&gt; field so you know which set you got. Just do not tell yourself that half a day of TLE age is what breaks a tracker.&lt;/p&gt;

&lt;p&gt;There is a punchline here I did not expect. Remember &lt;code&gt;api.wheretheiss.at&lt;/code&gt; from section 1, the one that was right? Pull its &lt;code&gt;/tles&lt;/code&gt; endpoint and compare: it is propagating this exact element set, line for line, the 23.7-hour one. The API I am about to hold up as correct is running on the feed I just called "twice as stale." Both things are true, and that is the point: for this satellite, at this timescale, TLE age costs about a kilometre. The thing that costs 9,300 km is something else entirely.&lt;/p&gt;

&lt;p&gt;(Two footnotes. The ISS &lt;strong&gt;reboosts&lt;/strong&gt;: a TLE fitted before a maneuver is wrong the moment the thrusters fire, at any age. And that same element set shows up as 23.7 hours here and 24.3 hours in section 1, because I fetched it twice, half an hour apart. Age is a stopwatch reading, not a property of the data.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; quick TLE lookups when you do not want to parse CelesTrak's formats. Read the &lt;code&gt;date&lt;/code&gt;. Docs: &lt;a href="https://tle.ivanstanojevic.me/" rel="noopener noreferrer"&gt;tle.ivanstanojevic.me&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. JPL SSD/CNEOS: four keyless endpoints, and errors hidden inside 200s
&lt;/h2&gt;

&lt;p&gt;NASA JPL's Solar System Dynamics group runs a set of APIs at &lt;code&gt;ssd-api.jpl.nasa.gov&lt;/code&gt; that are genuinely, fully keyless. This is one provider with four endpoints worth knowing, so I am counting it once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sbdb.api&lt;/code&gt;&lt;/strong&gt; looks up any small body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://ssd-api.jpl.nasa.gov/sbdb.api?sstr=433"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"fullname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"433 Eros (A898 PA)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"neo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orbit_class"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Amor"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"orbit"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"orbit_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"659"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"first_obs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1893-10-29"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"last_obs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2021-05-13"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"data_arc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"46582"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"n_obs_used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"moid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0.149"&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;first_obs: 1893-10-29&lt;/code&gt;. My first draft said Eros therefore has a 133-year observation arc, because 2026 minus 1893 is 133 and I can do arithmetic.&lt;/p&gt;

&lt;p&gt;Look three fields to the right. &lt;code&gt;data_arc: 46582&lt;/code&gt;. Days. That is &lt;strong&gt;127 years&lt;/strong&gt;, not 133, and the reason is sitting right next to it: &lt;code&gt;last_obs: 2021-05-13&lt;/code&gt;. The arc ends where the observations end, not where my calendar does. JPL puts the answer in the body as an integer so that nobody has to subtract dates, and I subtracted dates anyway, in a post whose entire argument is &lt;em&gt;read the field&lt;/em&gt;. Nine thousand one hundred and thirty observations went into that orbit and I could not be bothered to read one key.&lt;/p&gt;

&lt;p&gt;Read the field. That is the whole post, hiding inside an asteroid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cad.api&lt;/code&gt;&lt;/strong&gt; lists close approaches. Everything passing within 0.05 au in the next month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://ssd-api.jpl.nasa.gov/cad.api?dist-max=0.05&amp;amp;date-min=2026-07-14&amp;amp;date-max=2026-08-14"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"des"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"orbit_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"jd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"cd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="s2"&gt;"2026 MQ3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"2461237.959674506"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"2026-Jul-16 11:02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"0.0319436706577196"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"21.95"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Nineteen. &lt;strong&gt;&lt;code&gt;fireball.api&lt;/code&gt;&lt;/strong&gt; gives you atmospheric impact events detected by US government sensors, with energy and coordinates. &lt;strong&gt;&lt;code&gt;scout.api&lt;/code&gt;&lt;/strong&gt; covers newly-discovered objects not yet catalogued, and it carries &lt;code&gt;lastRun&lt;/code&gt; and &lt;code&gt;tEphem&lt;/code&gt; fields, which is a service admitting in the body how recently it thought about the problem.&lt;/p&gt;

&lt;p&gt;The gotcha is the sibling endpoint, &lt;code&gt;sentry.api&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;" [HTTP %{http_code}]&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://ssd-api.jpl.nasa.gov/sentry.api?des=99942"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"signature"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NASA/JPL Sentry Data API"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"removed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2021-02-21 08:22:28"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"specified object removed"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&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;HTTP 200. The body says &lt;code&gt;error&lt;/code&gt;. It also says exactly when: &lt;code&gt;removed: 2021-02-21&lt;/code&gt;. Apophis came off the impact-risk table in February 2021; NASA's public all-clear for the next hundred years came separately, in March, after the Goldstone radar pass refined the orbit. Two events, a few weeks apart, and I am flagging that because it would be very easy to fuse them into one tidy sentence and be wrong. Either way it is good news for Earth and bad news for &lt;code&gt;raise_for_status()&lt;/code&gt;. Branch on the &lt;code&gt;error&lt;/code&gt; key, not the status code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; asteroid data, close approaches, planetary defense projects. Docs: &lt;a href="https://ssd-api.jpl.nasa.gov/" rel="noopener noreferrer"&gt;ssd-api.jpl.nasa.gov&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. JPL Horizons: professional ephemerides, wrapped in a lie about JSON
&lt;/h2&gt;

&lt;p&gt;Horizons is the system planetary scientists actually use. It will compute the position of any body in the solar system, from any observer, at any time, to absurd precision. Keyless, over HTTP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: Mars, seen from Earth's center&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://ssd.jpl.nasa.gov/api/horizons.api?format=json&amp;amp;COMMAND='499'&amp;amp;OBJ_DATA='NO'&amp;amp;MAKE_EPHEM='YES'&amp;amp;EPHEM_TYPE='OBSERVER'&amp;amp;CENTER='500@399'&amp;amp;START_TIME='2026-07-15'&amp;amp;STOP_TIME='2026-07-16'&amp;amp;STEP_SIZE='1%20d'&amp;amp;QUANTITIES='1'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;*****&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Ephemeris / API_USER Mon Jul 13 11:51:55 2026 Pasadena, USA&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;*****&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Target body name: Mars (499)   {source: mar099}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Center body name: Earth (399)  {source: DE441}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You asked for JSON. You got JSON: one key, &lt;code&gt;result&lt;/code&gt;, holding the same fixed-width text table Horizons has emitted since the 1990s, newlines and asterisks and all. The JSON wrapper is transport, not structure. You still have to parse the text between the &lt;code&gt;$$SOE&lt;/code&gt; and &lt;code&gt;$$EOE&lt;/code&gt; markers by hand.&lt;/p&gt;

&lt;p&gt;I say this with affection. It is a thirty-year-old scientific instrument with a REST veneer, and it is one of the most powerful free things on the internet. Just do not plan on &lt;code&gt;body["result"]["mars"]["ra"]&lt;/code&gt; existing.&lt;/p&gt;

&lt;p&gt;One more thing, and it matters later. Point Horizons at the ISS instead of Mars, with &lt;code&gt;COMMAND='-125544'&lt;/code&gt;, and its header volunteers where its own trajectory comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Revised: Jul 13, 2026  International Space Station (ISS) / (Earth)     -125544

  Trajectory is TLE-based. Predicts run for 4 weeks into future, but are of
  low accuracy for times more than a few days past the revision date above.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Trajectory is TLE-based.&lt;/code&gt; Hold on to that sentence. It is going to demolish something of mine about six sections from now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; real ephemerides, planetary positions, spacecraft trajectories.&lt;/p&gt;

&lt;p&gt;The docs are at &lt;a href="https://ssd-api.jpl.nasa.gov/doc/horizons.html" rel="noopener noreferrer"&gt;ssd-api.jpl.nasa.gov/doc/horizons.html&lt;/a&gt;, and they are worth an hour of your life. Almost nobody reads them, which is why almost everybody reaches for a worse API.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. NASA Exoplanet Archive: 6,319 planets, 39,978 rows, and a real query language
&lt;/h2&gt;

&lt;p&gt;The Exoplanet Archive exposes a TAP service, which means you send it ADQL (a dialect of SQL for astronomy) and it returns rows. No key, no signup, and it is the actual planet catalog, not a summary of one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+count(*)+from+ps&amp;amp;format=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count(*)"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;39978&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;Thirty-nine thousand, nine hundred seventy-eight rows, as of July 13, 2026. And that is the first trap, this whole post compressed into one query: &lt;strong&gt;that is not 39,978 planets.&lt;/strong&gt; The &lt;code&gt;ps&lt;/code&gt; table holds one row per published parameter set, so a well-studied planet appears in it many times over. Ask the question you actually meant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+count(*)+from+pscomppars&amp;amp;format=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count(*)"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6319&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;6,319 planets. Same archive, same keyless endpoint, same clean HTTP 200, and a number 6.3 times smaller. &lt;code&gt;ps&lt;/code&gt; is the literature; &lt;code&gt;pscomppars&lt;/code&gt; is one row per planet. I ran the wrong one first and wrote the wrong number into my own draft, which is exactly the failure mode this post is about: nothing in the status line tells you which of those two questions you asked.&lt;/p&gt;

&lt;p&gt;You can slice it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+top+2+pl_name,disc_year,pl_orbper+from+ps+where+disc_year&amp;gt;2024&amp;amp;format=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"pl_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"KMT-2023-BLG-1896L b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"disc_year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pl_orbper"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"pl_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OGLE-2015-BLG-1609L b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"disc_year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pl_orbper"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas, and the second is the fun one. First, &lt;code&gt;pl_orbper&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; on both rows: these are microlensing detections, and microlensing does not give you an orbital period. Nulls are everywhere in this table and they are honest, not broken.&lt;/p&gt;

&lt;p&gt;Second, break the query on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;" [HTTP %{http_code}]&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+nope+from+ps&amp;amp;format=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;VOTABLE&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.4"&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.ivoa.net/xml/VOTable/v1.3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;RESOURCE&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"results"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;INFO&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"QUERY_STATUS"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"ERROR"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
ORA-00904: 'NOPE': invalid identifier
&lt;span class="nt"&gt;&amp;lt;/INFO&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/RESOURCE&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/VOTABLE&amp;gt;&lt;/span&gt; [HTTP 400]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked for &lt;code&gt;format=json&lt;/code&gt;. On error I got XML, plus an Oracle error code leaking through from the database underneath. The success path and the failure path do not speak the same language. Write your parser accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; exoplanet dashboards, statistics, anything where you want to run real queries instead of paging through someone's REST wrapper. Docs: &lt;a href="https://exoplanetarchive.ipac.caltech.edu/docs/TAP/usingTAP.html" rel="noopener noreferrer"&gt;exoplanetarchive.ipac.caltech.edu/docs/TAP/usingTAP.html&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. NASA Image and Video Library: the NASA API everyone forgets is keyless
&lt;/h2&gt;

&lt;p&gt;Everyone knows &lt;code&gt;api.nasa.gov&lt;/code&gt; and everyone forgets it wants a key (more on that below). &lt;code&gt;images-api.nasa.gov&lt;/code&gt; is a separate service and it wants nothing at all.&lt;/p&gt;

&lt;p&gt;While I was checking that, I counted, because my draft claimed this was "one of the two NASA services that are truly keyless" and I had not actually counted anything. There are at least five. On July 13, 2026 all of these returned HTTP 200 with no key: &lt;code&gt;images-api.nasa.gov&lt;/code&gt;, JPL's &lt;code&gt;ssd-api.jpl.nasa.gov&lt;/code&gt;, Horizons at &lt;code&gt;ssd.jpl.nasa.gov&lt;/code&gt;, the NASA Exoplanet Archive hosted at IPAC, and EONET, the natural-event tracker at &lt;code&gt;eonet.gsfc.nasa.gov&lt;/code&gt;. The "NASA needs a key" folklore is wrong in five different places at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://images-api.nasa.gov/search?q=apollo%2011&amp;amp;media_type=image"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"collection"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"total_hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1510&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"href"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://images-assets.nasa.gov/image/jsc2007e034221/collection.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;"data"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"nasa_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"jsc2007e034221"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Apollo 11 spacecraft pre-launch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"date_created"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1969-07-11T00:00:00Z"&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;1,510 Apollo 11 images. The gotcha is that the search does not give you image URLs. It gives you a &lt;code&gt;href&lt;/code&gt; pointing at a second JSON document, and you have to fetch that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://images-assets.nasa.gov/image/jsc2007e034221/collection.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="s2"&gt;"http://images-assets.nasa.gov/image/jsc2007e034221/jsc2007e034221~orig.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="s2"&gt;"http://images-assets.nasa.gov/image/jsc2007e034221/jsc2007e034221~large.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Two hops, always. And note the scheme: those asset URLs come back as &lt;code&gt;http://&lt;/code&gt;, not &lt;code&gt;https://&lt;/code&gt;, so a strict Content-Security-Policy on your page will block them unless you rewrite the scheme yourself. Small thing. Ruins an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; free, high-resolution, public-domain space imagery. Docs: &lt;a href="https://images.nasa.gov/docs/images.nasa.gov_api_docs.pdf" rel="noopener noreferrer"&gt;images.nasa.gov/docs/images.nasa.gov_api_docs.pdf&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. NOAA SWPC: the "current" geomagnetic index that cannot be current
&lt;/h2&gt;

&lt;p&gt;The Space Weather Prediction Center publishes solar and geomagnetic data as flat JSON files. No key, no auth, no rate limit worth mentioning. The planetary K-index tells you whether a geomagnetic storm is underway, which is what aurora apps and HF radio operators live on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"time_tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-06T00:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"Kp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;2.67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"a_running"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"station_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"time_tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-13T15:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"Kp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;2.33&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"a_running"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"station_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&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;Sixty-two records at a three-hour cadence, which is just under eight days of history. I pulled this at 19:00 UTC. The last &lt;code&gt;time_tag&lt;/code&gt; says 15:00 UTC, and the lazy read is "the current value is four hours old."&lt;/p&gt;

&lt;p&gt;The lazy read is mine, and it is wrong in a way that matters. &lt;code&gt;time_tag&lt;/code&gt; is not the moment of a measurement. It is the &lt;strong&gt;start of a three-hour bin&lt;/strong&gt;. I checked every one of the 61 gaps in that array and they are all exactly 3.0 hours. So the newest label is at minimum three hours behind the wall clock by construction, before you add any processing lag at all, and the four hours I measured is the age of the &lt;em&gt;label&lt;/em&gt;, not of the physics.&lt;/p&gt;

&lt;p&gt;Two consequences, and I shipped the first one broken:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;max_age&lt;/code&gt; of three hours on this feed can never pass.&lt;/strong&gt; Not "rarely." Never. My first draft gated it at exactly &lt;code&gt;3 * 3600&lt;/code&gt; and called the resulting rejection "the gate working." It was a threshold that could only ever say no, which is not a gate, it is a wall.&lt;/li&gt;
&lt;li&gt;Look at &lt;code&gt;station_count: 8&lt;/code&gt;. This is the &lt;strong&gt;estimated&lt;/strong&gt; Kp, computed from eight ground magnetometers in near-real-time. The definitive index is published later by GFZ Potsdam. If you need the real number for science rather than for a pretty aurora badge, this is not it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a bug. It is the correct behavior of a correct product. But if you wrote &lt;code&gt;current_kp = data[-1]["Kp"]&lt;/code&gt; and put it on a dashboard labeled "now," you have already told your first lie, and the API never contradicted you. It gave you a 200 and a &lt;code&gt;time_tag&lt;/code&gt; and trusted you to read it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; aurora forecasts, geomagnetic storm alerts, radio propagation. Read the &lt;code&gt;time_tag&lt;/code&gt;, and know what it is the timestamp &lt;em&gt;of&lt;/em&gt;. Docs: &lt;a href="https://www.swpc.noaa.gov/products-and-data" rel="noopener noreferrer"&gt;swpc.noaa.gov/products-and-data&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Launch Library 2: 364 upcoming launches, and a rate limit that will find you
&lt;/h2&gt;

&lt;p&gt;The Space Devs run Launch Library 2, a launch schedule that a lot of space apps are quietly built on. Anonymous access is keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=1&amp;amp;mode=list"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;364&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Falcon 9 Block 5 | Starlink Group 15-14"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Go for Launch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"abbrev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Go"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"net"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-14T01:17:14Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"last_updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-13T13:07:42Z"&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;364 launches on the books. Note the two time fields: &lt;code&gt;net&lt;/code&gt; is the projected launch time (No Earlier Than, and it moves constantly), and &lt;code&gt;last_updated&lt;/code&gt; is when a human touched this record. Both matter, for different reasons.&lt;/p&gt;

&lt;p&gt;The honest limitation: anonymous calls are rate-limited hard, and the API does not send you &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers to warn you. I checked; it exposes none. You find out by getting a 429. A free account raises the ceiling, and there is a &lt;code&gt;lldev.thespacedevs.com&lt;/code&gt; mirror with cached data for development. If you are building something real, sign up. If you are hacking a weekend project, the anonymous tier is fine, but do not poll it in a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; launch schedules, agencies, rockets, crewed missions. Docs: &lt;a href="https://thespacedevs.com/llapi" rel="noopener noreferrer"&gt;thespacedevs.com/llapi&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. SatNOGS DB: the open satellite catalog with an honest &lt;code&gt;updated&lt;/code&gt; field
&lt;/h2&gt;

&lt;p&gt;SatNOGS is a global network of volunteer-run ground stations, and its database is an open catalog of satellites, transmitters, and telemetry decoders. Keyless for reads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://db.satnogs.org/api/satellites/?format=json&amp;amp;norad_cat_id=25544"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"sat_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"XSKZ-5603-1870-9019-3066"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"norad_cat_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;25544&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ISS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ZARYA, RS0ISS, NA1SS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"alive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"decayed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"launched"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1998-11-20T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"countries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"RU,US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2025-08-01T05:17:04.759872Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"telemetries"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"decoder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;}]}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The killer feature here is the transmitter database: frequencies, modes, and decoders for thousands of satellites, which is not information you can easily get anywhere else for free.&lt;/p&gt;

&lt;p&gt;And look at &lt;code&gt;updated&lt;/code&gt;. This record was last touched on 2025-08-01, roughly a year before I pulled it. That is completely correct, because the ISS has not changed its name or its status in that time. The point is that SatNOGS &lt;strong&gt;tells you&lt;/strong&gt;. It puts the age of its own truth in the payload and lets you decide whether a year is too long for your use case. Every API in this post that I trust does exactly this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; satellite metadata, radio frequencies, amateur ground-station work. Docs: &lt;a href="https://db.satnogs.org/api/" rel="noopener noreferrer"&gt;db.satnogs.org/api/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. SIMBAD: the object database where Andromeda is not a "Galaxy"
&lt;/h2&gt;

&lt;p&gt;SIMBAD, from the Centre de Donnees astronomiques de Strasbourg, is the reference database for astronomical objects outside the solar system. Its TAP endpoint takes ADQL, keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: TAP wants request+lang, and it is picky&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://simbad.cds.unistra.fr/simbad/sim-tap/sync"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"request=doQuery&amp;amp;lang=ADQL&amp;amp;format=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"query=SELECT main_id, ra, dec, otype FROM basic WHERE main_id = 'M  31'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"main_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ra"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"deg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="s2"&gt;"M  31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;10.684708333333333&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;41.268750000000004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AGN"&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;First gotcha: drop &lt;code&gt;request=doQuery&amp;amp;lang=ADQL&lt;/code&gt; and you get an HTTP 400, no matter how good your query is. TAP is an IVOA standard, not a REST API someone designed for you.&lt;/p&gt;

&lt;p&gt;Second gotcha, and it is the whole thesis of this post wearing a different hat. Look at &lt;code&gt;otype&lt;/code&gt; for M 31. Andromeda, the galaxy, comes back typed &lt;strong&gt;&lt;code&gt;AGN&lt;/code&gt;&lt;/strong&gt;. Not "Galaxy". Let me check two more famous ones:&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="s2"&gt;"M  31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AGN"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"M  51"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sy2"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"M  87"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AGN"&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;Andromeda: AGN. The Whirlpool: Sy2 (a Seyfert 2). M87: AGN. If you wrote &lt;code&gt;WHERE otype = 'Galaxy'&lt;/code&gt; you would get a clean HTTP 200 with a perfectly valid, non-empty result set and you would ship a galaxy browser that silently omits Andromeda, the Whirlpool, and M87. That is not a thought experiment. SIMBAD accepts the long label happily, and I counted what it hands back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: the query that looks right and is not&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://simbad.cds.unistra.fr/simbad/sim-tap/sync"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"request=doQuery&amp;amp;lang=ADQL&amp;amp;format=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"query=SELECT COUNT(*) AS n FROM basic WHERE otype = 'Galaxy'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"datatype"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"LONG"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="mi"&gt;4177456&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;Four million rows, HTTP 200, no warning, no error, and three of the most famous galaxies in the sky are not in there.&lt;/p&gt;

&lt;p&gt;To be precise, because SIMBAD is not being sloppy and I am not going to imply it is: Andromeda &lt;strong&gt;is&lt;/strong&gt; a galaxy. &lt;code&gt;otype&lt;/code&gt; holds the single most specific type, and SIMBAD's own hierarchy says so out loud. Query the &lt;code&gt;otypedef&lt;/code&gt; table and the &lt;code&gt;path&lt;/code&gt; for &lt;code&gt;AGN&lt;/code&gt; is literally &lt;code&gt;G &amp;gt; AGN&lt;/code&gt;; &lt;code&gt;Sy2&lt;/code&gt; is &lt;code&gt;G &amp;gt; AGN &amp;gt; SyG &amp;gt; Sy2&lt;/code&gt;. The database was right. My query was wrong.&lt;/p&gt;

&lt;p&gt;And the fix is two characters. TAP exposes that hierarchy with a &lt;code&gt;..&lt;/code&gt; suffix, which means "this type and everything under it":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: '..' walks the type tree&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://simbad.cds.unistra.fr/simbad/sim-tap/sync"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"request=doQuery&amp;amp;lang=ADQL&amp;amp;format=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"query=SELECT main_id, otype FROM basic WHERE otype = 'G..' AND main_id IN ('M  31','M  51','M  87')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="s2"&gt;"M  31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"AGN"&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="s2"&gt;"M  51"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Sy2"&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="s2"&gt;"M  87"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"AGN"&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;Two dots. That is the whole difference between a galaxy browser that works and one that ships a 200 with a hole in it. One warning before you paste it elsewhere: &lt;code&gt;..&lt;/code&gt; is a SIMBAD extension to its own &lt;code&gt;otype&lt;/code&gt; column, not portable ADQL. Try it against the Exoplanet Archive's TAP from section 6 and Oracle will tell you exactly what it thinks of you.&lt;/p&gt;

&lt;p&gt;Which is the lesson again, wearing its third hat today: &lt;code&gt;otype = 'Galaxy'&lt;/code&gt; returned a valid, non-empty, four-million-row answer, and nothing about that 200 was ever going to hint that I had asked the wrong question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it for:&lt;/strong&gt; star and deep-sky object coordinates, identifiers, classifications.&lt;/p&gt;

&lt;p&gt;Before you write a single query, go read what &lt;code&gt;otype&lt;/code&gt; actually means: &lt;a href="https://simbad.cds.unistra.fr/simbad/sim-tap" rel="noopener noreferrer"&gt;simbad.cds.unistra.fr/simbad/sim-tap&lt;/a&gt;. I did not, and it cost me a galaxy browser with three famous holes in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flagged: api.nasa.gov is not keyless, DEMO_KEY is not "no key"
&lt;/h2&gt;

&lt;p&gt;APOD, NeoWs, EPIC, and DONKI all live behind &lt;code&gt;api.nasa.gov&lt;/code&gt;, and every tutorial calls them free. They are free. They are not keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;" [HTTP %{http_code}]&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://api.nasa.gov/planetary/apod"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"API_KEY_MISSING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"No api_key was supplied. Get one at https://api.nasa.gov:443"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&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;A hard 403. Add &lt;code&gt;?api_key=DEMO_KEY&lt;/code&gt; and you get a 200, which is why the myth persists.&lt;/p&gt;

&lt;p&gt;Now, how strict is DEMO_KEY? The documentation says 30 requests per IP per hour and 50 per day, and my first draft printed those two numbers, sourced from the docs, in a post whose single rule is &lt;em&gt;the docs are not the data&lt;/em&gt;. So I went and hit it four times in a row and watched the headers instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: watch the header, not the docs&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"^HTTP&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;^x-ratelimit"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 200
x-ratelimit-limit: 10
x-ratelimit-remaining: 1

HTTP/2 200
x-ratelimit-limit: 10
x-ratelimit-remaining: 0

HTTP/2 429
x-ratelimit-limit: 10
x-ratelimit-remaining: 0

HTTP/2 429
x-ratelimit-limit: 10
x-ratelimit-remaining: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-ratelimit-limit: 10&lt;/code&gt;, and a 429 on the third call. I am not going to tell you the docs are wrong, because I do not know what window that 10 belongs to or what else my IP had been doing that hour. I am telling you what the server said when I asked it, on July 13, 2026, and that the server ships the number in a header so you never have to guess. It is a key, and a small one, and building on it is how a demo rots into a support ticket. I am not padding my count with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not on the list: three APIs the tutorials still recommend
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SpaceX API v4.&lt;/strong&gt; Still the first result a lot of people land on. Dead:&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="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://api.spacexdata.com/v4/launches/latest"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;525
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;525 is a Cloudflare origin SSL handshake failure: the edge is up, and the origin is not completing TLS with it. Note what that does and does not tell you. The backend might be perfectly alive behind an expired certificate, a closed 443, or a cipher mismatch. From out here you cannot tell, and it does not matter, because you cannot get launches out of it either way. Use Launch Library 2 instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solar System OpenData&lt;/strong&gt; (&lt;code&gt;api.le-systeme-solaire.net&lt;/code&gt;), which was keyless for years, now returns &lt;code&gt;401 Unauthorized: API key is missing. Ask your API key... Add use it on a bearer token&lt;/code&gt;. Gated in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;USNO Astronomical Applications&lt;/strong&gt; (&lt;code&gt;aa.usno.navy.mil/api/&lt;/code&gt;) did not complete a connection at all for me: curl exit, HTTP 000. Sunrise, sunset, moon phase, all unreachable from where I sat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate: read the timestamp out of the body
&lt;/h2&gt;

&lt;p&gt;Here is the wrapper. It does one thing: it pulls the time field out of the response body, compares it to now, and refuses an answer that is too old to be called data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local: pip install requests
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StaleData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;age_seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Accept an ISO-8601 string or a unix timestamp. Return age in seconds.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromtimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z&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;+00:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="c1"&gt;# SWPC and CelesTrak omit the zone
&lt;/span&gt;            &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_fresh&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="n"&gt;pick_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;GET url, read the timestamp OUT OF THE BODY, refuse the answer if it is old.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                 &lt;span class="c1"&gt;# a frozen server sails straight through this
&lt;/span&gt;    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;age_seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pick_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# this is the check that actually matters
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;StaleData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200 but &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;h old (max_age &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;h)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;

&lt;span class="n"&gt;CASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;# gate wheretheiss on the ELEMENTS it propagates, not on its own clock
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wheretheiss&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;https://api.wheretheiss.at/v1/satellites/25544/tles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tle_timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&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;open-notify&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;http://api.open-notify.org/iss-now.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;celestrak&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;https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;amp;FORMAT=json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EPOCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&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;swpc-kp&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;https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time_tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# time_tag opens a 3h bin: 3h is unreachable
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&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="n"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CASES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_fresh&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="n"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FRESH  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; age &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;StaleData&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STALE  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output, July 13, 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STALE  wheretheiss  200 but 25.4h old (max_age 6.0h)
FRESH  open-notify  age        1s
STALE  celestrak    200 but 13.0h old (max_age 6.0h)
FRESH  swpc-kp      age    19947s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now read line one and line two together, because that pair is the most useful thing I found all day.&lt;/p&gt;

&lt;p&gt;The gate &lt;strong&gt;flagged &lt;code&gt;wheretheiss&lt;/code&gt;&lt;/strong&gt;. The API that was right. It flagged it because I aimed the gate at &lt;code&gt;/tles&lt;/code&gt;, where it read the age of the elements underneath the answer: 25.4 hours, well past my six-hour ceiling.&lt;/p&gt;

&lt;p&gt;The gate &lt;strong&gt;passed &lt;code&gt;open-notify&lt;/code&gt;&lt;/strong&gt;. One second old. Perfect score. The API that is on the wrong side of the planet.&lt;/p&gt;

&lt;p&gt;My timestamp gate got both of them exactly backwards.&lt;/p&gt;

&lt;p&gt;It is still worth having, and I still ship it, because it catches honest staleness and honest staleness is most of what goes wrong: CelesTrak's 13-hour elements, SWPC's Kp sitting 5.5 hours behind the clock and squeaking under a ceiling I set at six on purpose. My &lt;code&gt;max_age&lt;/code&gt; values are opinions, not physics. Six hours for orbital elements because I decided a kilometre of drift is my budget; six for Kp because three is arithmetically impossible against a three-hour bin. Pick your own and write down why.&lt;/p&gt;

&lt;p&gt;But a gate that reads one response can only ever ask the server about itself. And this server lies about itself, fluently, with a fresh timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gate fails, and the arbiter I got wrong
&lt;/h2&gt;

&lt;p&gt;And then there is line two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FRESH open-notify age 1s&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open Notify is the API from the top of this post, the one that is about 9,300 km wrong. It passed the freshness gate with a perfect score, because its &lt;code&gt;timestamp&lt;/code&gt; field is the server's clock at the moment you ask. It stamps &lt;code&gt;now&lt;/code&gt; on every response.&lt;/p&gt;

&lt;p&gt;It gets worse, and this is the part that changed how I think about liveness checks. The position &lt;strong&gt;changes between calls.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ts 1783974529   lat -22.9871   lon 128.4535
ts 1783974578   lat -25.3113   lon 130.6121
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty-nine seconds, 339 km, which is 6.91 km/s: the same ground-track speed I measured at the top of this post. Smooth motion along something that has the exact shape of an orbit. I want to be careful here, because it would be easy and satisfying to tell you what that server is doing internally, and I cannot see inside it. What is observable is enough: it reports a position that no currently published element set produces, it moves, and its crew roster still lists the people who were aboard in mid-2024.&lt;/p&gt;

&lt;p&gt;Run down the checklist a careful engineer would actually write:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP 200. Yes.&lt;/li&gt;
&lt;li&gt;Valid JSON, parses cleanly. Yes.&lt;/li&gt;
&lt;li&gt;Matches the schema, no nulls. Yes.&lt;/li&gt;
&lt;li&gt;Body says &lt;code&gt;"message": "success"&lt;/code&gt;. Yes.&lt;/li&gt;
&lt;li&gt;Timestamp field is current. &lt;strong&gt;Yes.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Value changes between polls, so it is not a frozen cache. &lt;strong&gt;Yes.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single one passes. And the answer is on the wrong side of the planet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The arbiter that proved nothing
&lt;/h3&gt;

&lt;p&gt;So I built an arbiter. Pull the current elements, propagate them with SGP4, convert to lat/lon, ask both APIs the same question at the same instant, and see who lands where. It ran. It printed a beautiful result, which I wrote straight into this draft: &lt;code&gt;wheretheiss&lt;/code&gt; off by 3 km, &lt;code&gt;open-notify&lt;/code&gt; off by 9,300. Two independent methods agreeing to within 3 km. Case closed, ship it.&lt;/p&gt;

&lt;p&gt;It is not two independent methods. It is one method, run twice.&lt;/p&gt;

&lt;p&gt;Here is the shortest possible demonstration. Take the element set that &lt;code&gt;wheretheiss&lt;/code&gt; serves from its own &lt;code&gt;/tles&lt;/code&gt; endpoint, the one printed back in section 1, and propagate it with SGP4 to &lt;code&gt;wheretheiss&lt;/code&gt;'s own timestamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local: pip install sgp4 requests
# (sgp4_at and great_circle are defined in the full listing below)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sgp4.api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Satrec&lt;/span&gt;

&lt;span class="n"&gt;wi&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.wheretheiss.at/v1/satellites/25544&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.wheretheiss.at/v1/satellites/25544/tles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;sat&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Satrec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;twoline2rv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;tles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# ITS elements
&lt;/span&gt;&lt;span class="n"&gt;mine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sgp4_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                     &lt;span class="c1"&gt;# ITS timestamp
&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;wi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;longitude&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my sgp4      lat %9.4f  lon %9.4f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;mine&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wheretheiss  lat %9.4f  lon %9.4f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;W&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distance     %.2f km&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;great_circle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tle_timestamp 1783883409  (the elements wheretheiss is propagating)
my sgp4      lat   26.5017  lon   84.7225
wheretheiss  lat   26.5017  lon   84.7225
distance     0.00 km
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Not "close." Identical to four decimal places, because &lt;code&gt;api.wheretheiss.at&lt;/code&gt; &lt;strong&gt;is&lt;/strong&gt; an SGP4 propagator running on that element set. Reproducing its output is a unit test of my &lt;code&gt;pip install sgp4&lt;/code&gt;, not corroboration of anything about the sky.&lt;/p&gt;

&lt;p&gt;My 3 km only existed because my arbiter happened to grab a &lt;em&gt;different&lt;/em&gt; element set (CelesTrak's, twelve hours newer) and section 3 already told you, with a number, that those two sets sit 1.0 km apart. I wrote the word "independent" about thirty lines after admitting that both feeds carry the same elements. The code ran. The output was real. The comparison was rigged, and it did not survive review.&lt;/p&gt;

&lt;p&gt;Which is the disease of this entire post, caught in my own draft: a green check that means nothing. Nothing I could have added to my test suite would have found it. Only someone asking "independent of &lt;em&gt;what&lt;/em&gt;?" would.&lt;/p&gt;

&lt;h3&gt;
  
  
  The arbiter that is at least a different implementation
&lt;/h3&gt;

&lt;p&gt;So go get a real second opinion. NASA runs one, keyless, and it has been sitting in section 5 of this post the whole time: JPL Horizons will compute the ISS for you if you hand it &lt;code&gt;COMMAND='-125544'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And Horizons, being a grown-up, tells you in its own header what it is: &lt;code&gt;Trajectory is TLE-based.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;There it is. &lt;strong&gt;There is no keyless oracle for where the ISS is.&lt;/strong&gt; Horizons ingests the same public element sets everyone else does. So does &lt;code&gt;wheretheiss&lt;/code&gt;. So does my script. The entire visible world of free ISS tracking hangs off one lineage of tracking data, produced by the 18th Space Defense Squadron, and if that lineage were wrong we would all be wrong together and in perfect agreement about it.&lt;/p&gt;

&lt;p&gt;So I am not going to sell you an oracle. What Horizons gives you is weaker and still worth a great deal: an independent &lt;strong&gt;implementation&lt;/strong&gt;. JPL's ingest, JPL's propagator, JPL's frame and rotation handling, none of my code, none of &lt;code&gt;wheretheiss&lt;/code&gt;'s server. I would rather hand you the weaker claim that is true.&lt;/p&gt;

&lt;p&gt;The trick for getting a ground track out of it is nice: ask Horizons for &lt;strong&gt;Earth&lt;/strong&gt;, observed &lt;strong&gt;from the ISS&lt;/strong&gt;, and request quantity 14, the sub-observer point. The point on Earth directly under the observer is the ground track, computed by JPL, with all the frame rotation handled by people who do this for a living.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local: pip install sgp4 requests
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sgp4.api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Satrec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jday&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gmst_deg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jd&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                       &lt;span class="c1"&gt;# Vallado, GMST from a Julian date (UT1)
&lt;/span&gt;    &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;2451545.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;36525.0&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;67310.54841&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;876600&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mf"&gt;3600.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;8640184.812866&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.093104&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;6.2e-6&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mf"&gt;86400.0&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="mf"&gt;360.0&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;86400.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mf"&gt;360.0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;teme_to_geodetic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jd&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;            &lt;span class="c1"&gt;# TEME -&amp;gt; ECEF -&amp;gt; WGS84 lat/lon
&lt;/span&gt;    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gmst_deg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jd&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&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="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;r&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="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;6378.137&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;298.257223563&lt;/span&gt;
    &lt;span class="n"&gt;e2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                 &lt;span class="c1"&gt;# standard WGS84 latitude iteration
&lt;/span&gt;        &lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;
        &lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;e2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="n"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;great_circle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;la1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;la2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&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="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;q&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="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;la2&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;la1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;la1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;la2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;lo2&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lo1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;6371.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sgp4_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unix_ts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;              &lt;span class="c1"&gt;# propagate to ONE API's OWN timestamp
&lt;/span&gt;    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromtimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unix_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;jd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;microsecond&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1e6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# keep the sub-second part
&lt;/span&gt;    &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sgp4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;teme_to_geodetic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;horizons_subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unix_ts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Target = Earth, observer = the ISS, quantity 14 = sub-observer point.
    That IS the ground track, and JPL does the frame work.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;jd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2440587.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;unix_ts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;86400.0&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://ssd.jpl.nasa.gov/api/horizons.api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;format&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;text&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;COMMAND&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;399&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;CENTER&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;500@-125544&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;MAKE_EPHEM&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;YES&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;EPHEM_TYPE&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;OBSERVER&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;QUANTITIES&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;14&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;TLIST_TYPE&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;JD&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;TIME_TYPE&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;UT&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;TLIST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jd&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$$SOE&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$$EOE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;      &lt;span class="c1"&gt;# E-lon, lat
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;

&lt;span class="n"&gt;wi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.wheretheiss.at/v1/satellites/25544&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://api.open-notify.org/iss-now.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;amp;FORMAT=TLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;ts_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;wi&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;longitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;O&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss_position&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;latitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss_position&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;longitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="n"&gt;sat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Satrec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;twoline2rv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;el&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="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;S_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S_o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sgp4_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_w&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sgp4_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# each at its OWN timestamp
&lt;/span&gt;&lt;span class="n"&gt;H_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H_o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;horizons_subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts_w&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;horizons_subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts_o&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wheretheiss  vs JPL Horizons   %7.1f km&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;great_circle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H_w&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my sgp4      vs JPL Horizons   %7.1f km&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;great_circle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;S_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H_w&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wheretheiss  vs my sgp4        %7.1f km&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;great_circle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S_w&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;open-notify  vs JPL Horizons   %7.0f km&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;great_circle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;O&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H_o&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note one small fix in there, which cost me most of my 3 km: each API is judged at the instant &lt;strong&gt;it&lt;/strong&gt; stamped, with the microseconds kept. The first version rounded to whole seconds, and a whole second of ISS is 7.7 km. My arbiter's error bar was bigger than the disagreement I was using it to measure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ts_w 1783974793   ts_o 1783974795   (each API judged at the instant IT stamped)
                       lat        lon
JPL Horizons  @ts_w   29.0128    82.1929
wheretheiss   @ts_w   28.9979    82.1926
my sgp4       @ts_w   29.0052    82.1859
JPL Horizons  @ts_o   28.9213    82.2889
open-notify   @ts_o  -35.0240   141.4149

wheretheiss  vs JPL Horizons       1.7 km
my sgp4      vs JPL Horizons       1.1 km
wheretheiss  vs my sgp4            1.0 km
open-notify  vs JPL Horizons      9432 km
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three implementations, one element lineage, landing within 1.7 km of each other over the Himalayas. And Open Notify, judged at the instant it stamped its own answer, 9,432 km away over southern Australia.&lt;/p&gt;

&lt;p&gt;Do not read the 1.7 km as a quality score. It is the noise floor of the method: TLE plus SGP4 is good to a couple of kilometres for the ISS on a calm day, my GMST rotation is an approximation, and the three of us are propagating fits anchored at different hours. Anything under a few kilometres here means "the same place." I am not selling you a precision I do not have.&lt;/p&gt;

&lt;p&gt;What the run does establish is narrower than "Open Notify is wrong" and much harder to argue with: &lt;strong&gt;Open Notify is not tracking the satellite the rest of us are tracking.&lt;/strong&gt; It is serving a position that no published element set produces, under HTTP 200, with a timestamp one second old.&lt;/p&gt;

&lt;p&gt;The corroborating evidence is the crew roster. Open Notify's other endpoint tells you who is in space:&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;"http://api.open-notify.org/astros.json"&lt;/span&gt;    &lt;span class="c"&gt;# HTTP 200&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://ll.thespacedevs.com/2.2.0/astronaut/?in_space=true"&lt;/span&gt;  &lt;span class="c"&gt;# HTTP 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open-notify   number = 12   Kononenko, Chub, Caldwell Dyson, Dominick,
                            Barratt, Epps, Grebenkin, Wilmore, Sunita Williams,
                            Li Guangsu, Li Cong, Ye Guangfu
thespacedevs  count  = 11   Meir, Starman, Kud-Sverchkov, Hathaway,
                            Christopher Williams, Fedyaev, Adenot, Zhu Yangzhu,
                            Mikayev, Zhang Zhiyuan, Lai Ka-ying
overlap = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero names in common. (The only shared surname is Williams, and they are two different people.) Both HTTP 200, both a valid list, both cheerfully certain.&lt;/p&gt;

&lt;p&gt;Now, an honest caveat, because I am not going to pretend I have an oracle here either: &lt;strong&gt;The Space Devs is not ground truth.&lt;/strong&gt; Its &lt;code&gt;in_space=true&lt;/code&gt; list includes "Starman," the mannequin SpaceX put in a Tesla in 2018, typed &lt;code&gt;Non-Human&lt;/code&gt;, and the API reports 3,078 days of flight time for him. I am not telling you that list is right. I am telling you these two 200s cannot both be right, and that is the entire lesson. Open Notify's roster happens to be internally consistent with a snapshot from 2024, which is a strong hint about which one froze. But I did not need to know who is actually aboard the ISS to know that something here is broken. Two disagreeing successes are all the evidence you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what do you actually do?
&lt;/h2&gt;

&lt;p&gt;Two layers, and they catch different diseases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer one, cheap: read the body's own clock.&lt;/strong&gt; &lt;code&gt;EPOCH&lt;/code&gt;, &lt;code&gt;time_tag&lt;/code&gt;, &lt;code&gt;lastRun&lt;/code&gt;, &lt;code&gt;updated&lt;/code&gt;. Set a &lt;code&gt;max_age&lt;/code&gt; you can defend and reject anything older. This catches honest staleness: CelesTrak's 13-hour elements, SWPC's Kp label sitting behind a three-hour bin, the TLE feed twice as old as its neighbor. Most of your data problems are here, and thirty lines of &lt;code&gt;fetch_fresh&lt;/code&gt; will find them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer two, expensive: cross-check against a second implementation.&lt;/strong&gt; This is the only thing that catches a server stamping &lt;code&gt;now&lt;/code&gt; on garbage. It is not free, so do not do it on every call. Run it on a schedule, as a canary, and alert when two sources that should agree stop agreeing.&lt;/p&gt;

&lt;p&gt;And when you build that canary, ask the question I failed to ask for a whole draft: &lt;em&gt;independent of what?&lt;/em&gt; A second source that is downstream of the first one is not a second source. It is a mirror, and it will agree with anything.&lt;/p&gt;

&lt;p&gt;If I had shipped an ISS tracker on Open Notify, no amount of schema validation, status checking, null-guarding, or timestamp reading would have saved me. Only a second opinion would, and only if I had checked that it was actually a second one.&lt;/p&gt;

&lt;p&gt;I learned the first half of that in a different domain, expensively. Across 2,190 production scraper runs, the incidents that actually hurt were never the crashes. A 500 wakes you up and you fix it by lunch. The response that comes back valid, non-empty, correctly shaped, and quietly wrong is the one that sits in a column until somebody notices a report looks off, and by then you are backfilling.&lt;/p&gt;

&lt;p&gt;Space just makes it visible, because a wrong satellite position is 9,000 km of visible. Your API is probably lying in a smaller, quieter way. Go read its timestamp. Then go check what your check is checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the best free space API with no key?&lt;/strong&gt;&lt;br&gt;
It depends on the job, and several are keyless together. Where the ISS at gives live station position. CelesTrak gives the orbital elements everything else is built on. JPL SSD/CNEOS covers asteroids and close approaches, Launch Library 2 covers launches, and the NASA Exoplanet Archive lets you run real ADQL queries against 6,319 confirmed planets (the &lt;code&gt;ps&lt;/code&gt; table has 39,978 rows, because it stores one row per published parameter set, not one per planet). All returned HTTP 200 with no key on July 13, 2026. Whichever you pick, read the timestamp field in the body before you trust the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the NASA API require an API key?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;api.nasa.gov&lt;/code&gt; (APOD, NeoWs, EPIC, DONKI) does: without a key it returns HTTP 403 with &lt;code&gt;API_KEY_MISSING&lt;/code&gt;. &lt;code&gt;DEMO_KEY&lt;/code&gt; works but is a shared public credential (its live response carried &lt;code&gt;x-ratelimit-limit: 10&lt;/code&gt; and started returning 429 on my third call), so it is a key, not "no key." At least five other NASA-funded services are genuinely keyless and returned HTTP 200 with no credentials on July 13, 2026: the NASA Image and Video Library (&lt;code&gt;images-api.nasa.gov&lt;/code&gt;), JPL's &lt;code&gt;ssd-api.jpl.nasa.gov&lt;/code&gt; suite, JPL Horizons (&lt;code&gt;ssd.jpl.nasa.gov&lt;/code&gt;), the NASA Exoplanet Archive at IPAC, and EONET (&lt;code&gt;eonet.gsfc.nasa.gov&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the Open Notify ISS API still working in 2026?&lt;/strong&gt;&lt;br&gt;
It returns HTTP 200 and it is not working. On July 13, 2026 its &lt;code&gt;iss-now.json&lt;/code&gt; position was 9,432 km away from where JPL Horizons put the ISS at the same instant, while &lt;code&gt;api.wheretheiss.at&lt;/code&gt; and my own SGP4 propagation of CelesTrak's current elements both agreed with Horizons to within 1.7 km. Its &lt;code&gt;astros.json&lt;/code&gt; crew roster still lists the people who were aboard in mid-2024, Wilmore and Williams among them. It also fails TLS, so it only answers over plain &lt;code&gt;http://&lt;/code&gt;. Treat it as an exhibit, not a dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if an API's data is stale when it returns 200?&lt;/strong&gt;&lt;br&gt;
Read the timestamp out of the response body, not the status line. Real APIs publish the age of their own truth: CelesTrak has &lt;code&gt;EPOCH&lt;/code&gt;, NOAA SWPC has &lt;code&gt;time_tag&lt;/code&gt;, JPL Scout has &lt;code&gt;lastRun&lt;/code&gt;, SatNOGS has &lt;code&gt;updated&lt;/code&gt;. Compare that field to now and reject anything older than a &lt;code&gt;max_age&lt;/code&gt; you chose deliberately. That catches frozen feeds. It does not catch a server that stamps the current time on stale inputs, and for that you need a second source to cross-check against, one that is genuinely a different implementation rather than a mirror of the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What replaced the SpaceX API?&lt;/strong&gt;&lt;br&gt;
The SpaceX v4 API at &lt;code&gt;api.spacexdata.com&lt;/code&gt; returned HTTP 525 (a Cloudflare origin SSL failure) when I tested it on July 13, 2026. Launch Library 2 from The Space Devs (&lt;code&gt;ll.thespacedevs.com&lt;/code&gt;) is the practical replacement: it is keyless for anonymous use, it covers all launch providers rather than one, and it returned 364 upcoming launches. Anonymous access is rate-limited, so a free account helps for anything real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get real asteroid and close-approach data for free?&lt;/strong&gt;&lt;br&gt;
Yes, from JPL, with no key. &lt;code&gt;ssd-api.jpl.nasa.gov/sbdb.api?sstr=433&lt;/code&gt; returns the full orbit of 433 Eros, including &lt;code&gt;first_obs: 1893-10-29&lt;/code&gt;, &lt;code&gt;last_obs: 2021-05-13&lt;/code&gt;, and &lt;code&gt;data_arc: 46582&lt;/code&gt; days, which is a 127-year observation arc built from 9,130 observations. &lt;code&gt;cad.api&lt;/code&gt; lists close approaches (19 objects within 0.05 au in the month I checked), &lt;code&gt;fireball.api&lt;/code&gt; lists atmospheric impact events, and &lt;code&gt;scout.api&lt;/code&gt; covers newly-discovered objects. One warning: &lt;code&gt;sentry.api&lt;/code&gt; can return HTTP 200 with an &lt;code&gt;error&lt;/code&gt; key in the body, so branch on the body, not the status.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every endpoint above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 13, 2026 before publishing; responses are trimmed, never reworded. I have not run a satellite-tracking service in production; the 2,190 runs are a scraping-and-enrichment domain, cited only as the origin of the read-the-body habit. I wrote up the same pattern for &lt;a href="https://blog.spinov.online/blog/free-government-apis-no-key/" rel="noopener noreferrer"&gt;free government data APIs&lt;/a&gt;, &lt;a href="https://blog.spinov.online/blog/free-fun-facts-apis-no-key/" rel="noopener noreferrer"&gt;free fun-facts APIs&lt;/a&gt;, and &lt;a href="https://blog.spinov.online/blog/free-mock-fake-data-apis-no-key/" rel="noopener noreferrer"&gt;free mock and fake-data APIs&lt;/a&gt;. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless layer I verify. And tell me: what is the longest a stale-but-successful API fooled you, and what finally caught it, a timestamp or a second opinion? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>space</category>
      <category>webdev</category>
    </item>
    <item>
      <title>11 Free Music APIs With No Key or Signup (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Tue, 14 Jul 2026 09:55:33 +0000</pubDate>
      <link>https://dev.to/0012303/11-free-music-apis-with-no-key-or-signup-2026-314f</link>
      <guid>https://dev.to/0012303/11-free-music-apis-with-no-key-or-signup-2026-314f</guid>
      <description>&lt;p&gt;On July 12, 2026 I asked a free lyrics API for the most famous Coldplay song there is. It answered in a few milliseconds: HTTP 200, valid JSON, a full array of twenty results. The top one was a song called "Help Is Round the Corner."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: no key needed&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://lrclib.net/api/search?q=yellow%20coldplay"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;13774&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"trackName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Help Is Round the Corner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artistName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Coldplay"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"albumName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Yellow - Single"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;156.0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;more...&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;Status 200. The array is not empty. It has exactly the shape you expect. And &lt;code&gt;results[0]&lt;/code&gt; is the wrong song. "Help Is Round the Corner" is a Coldplay B-side that shipped on the "Yellow" single back in 2000; the search matched the album name, ranked the B-side first, and handed it back with no hesitation. Every guard I reach for passes this response: &lt;code&gt;resp.ok&lt;/code&gt;, &lt;code&gt;len(results) &amp;gt; 0&lt;/code&gt;, &lt;code&gt;results[0]["trackName"]&lt;/code&gt; exists. The wrong entity walks straight past all of them and gets stapled to the wrong lyrics.&lt;/p&gt;

&lt;p&gt;My earlier keyless-API posts kept circling one idea from different angles. HTTP 200 does not mean the read worked, because the body can be empty. HTTP 201 Created does not mean a write happened, because the read-back returns 404. Then a 200 can parse, match your schema, and still hand you &lt;code&gt;null&lt;/code&gt;. This post moves the problem sideways. Here the status is 200, the body arrives, it parses, the list is full, every field is present and well-typed. The top row is just the wrong thing. &lt;strong&gt;A 200 with a full list is not a 200 with the right result.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is structural, and it is specific to music. There is no single ID for "the song Yellow by Coldplay." There is a studio single, a remaster, a live radio session, a karaoke track, a handful of covers, and a B-side that sits on the "Yellow" single. Every music search is a fuzzy ranking over that mess, so every &lt;code&gt;results[0]&lt;/code&gt; is a guess. A valid, non-empty, correctly shaped guess. The only thing that tells you whether it is the right guess is a number or a field in the body that most tutorials never read.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free music API&lt;/strong&gt; here means a public endpoint that returns music data (tracks, artists, lyrics, cover art, radio, audio features, listening history) with no API key, no signup, and no credit card. A URL you can paste into a terminal right now. Eleven clear that bar, and I re-verified every response below with a live &lt;code&gt;curl&lt;/code&gt; on July 12, 2026: real HTTP code, real body, trimmed but never reworded.&lt;/p&gt;

&lt;p&gt;One scope note first, so the numbers stay honest. I curl-verified all eleven APIs on July 12, 2026. I have &lt;strong&gt;not&lt;/strong&gt; run a music resolver in production. My 2,190 production scraper runs (962 of them on a single Trustpilot scraper) are a different domain, and I cite them for one reason only: they are why I read a body's own confidence fields instead of its status line. That number is not a claim about these eleven endpoints.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it returns&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;The fuzzy trap to watch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;MusicBrainz&lt;/td&gt;
&lt;td&gt;Artist / recording metadata + MBIDs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET musicbrainz.org/ws/2/recording?query=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, &lt;code&gt;score:100&lt;/code&gt;, top hit is a live bootleg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Cover Art Archive&lt;/td&gt;
&lt;td&gt;Album art by MBID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET coverartarchive.org/release-group/{mbid}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hit is a 307, miss is a 404, never a plain 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;iTunes Search&lt;/td&gt;
&lt;td&gt;Apple catalog search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET itunes.apple.com/search?term=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, fuzzy-matches a typo into real tracks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Deezer&lt;/td&gt;
&lt;td&gt;Track / artist search + ISRC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.deezer.com/search?q=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 carrying an error object in the body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Lyrics.ovh&lt;/td&gt;
&lt;td&gt;Plain lyrics by artist/title&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.lyrics.ovh/v1/{artist}/{title}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works, but the host flaps between days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;lrclib&lt;/td&gt;
&lt;td&gt;Time-synced lyrics&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET lrclib.net/api/search?q=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, &lt;code&gt;results[0]&lt;/code&gt; is a B-side, not the song&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Radio Browser&lt;/td&gt;
&lt;td&gt;Internet radio directory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET de1.api.radio-browser.info/json/stations/search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 lists dead streams; read &lt;code&gt;lastcheckok&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Open Opus&lt;/td&gt;
&lt;td&gt;Classical composers / works&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.openopus.org/composer/list/search/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Curated set; a miss returns &lt;code&gt;rows:0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;ListenBrainz&lt;/td&gt;
&lt;td&gt;Open listening-history stats&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.listenbrainz.org/1/stats/sitewide/artists&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read is keyless; submit needs a token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;AcousticBrainz&lt;/td&gt;
&lt;td&gt;Audio features (loudness, BPM, key)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET acousticbrainz.org/api/v1/{mbid}/low-level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 forever, dataset frozen since 2022&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Odesli / song.link&lt;/td&gt;
&lt;td&gt;Cross-platform track links&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.song.link/v1-alpha.1/links?url=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, but links are geo-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One flagged bonus and a short list of key-required names get their own section after the eleven.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. MusicBrainz: the score of 100 that means "string matched," not "right recording"
&lt;/h2&gt;

&lt;p&gt;MusicBrainz is the open music encyclopedia, the free MBID system that half the other APIs on this list key off. It is keyless, but it has two rules: send a real &lt;code&gt;User-Agent&lt;/code&gt; (a default client UA gets a 403) and stay under about one request per second. The happy path is clean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: real User-Agent is mandatory&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"my-app/1.0 (me@example.com)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://musicbrainz.org/ws/2/artist?query=Radiohead&amp;amp;fmt=json&amp;amp;limit=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artists"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"a74b1b7f-71a5-4011-9441-d0b5e4122711"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Radiohead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"GB"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Score 100, country GB, one MBID you can trust. Now search for a recording instead of an artist, and watch the ranking betray you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"my-app/1.0 (me@example.com)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://musicbrainz.org/ws/2/recording?query=Yellow%20AND%20artist:Coldplay&amp;amp;fmt=json&amp;amp;limit=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;146&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"recordings"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"f15eb81a-b021-4208-9f1c-f4db2a9f09ae"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Yellow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"disambiguation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"live, 2001-06-13: KBCO Studio C, Boulder, CO, USA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"releases"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"KBCO Studio C, Volume 13"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2001"&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;Read that top row carefully. The &lt;code&gt;title&lt;/code&gt; is exactly "Yellow." The &lt;code&gt;score&lt;/code&gt; is a perfect 100. If your code trusts &lt;code&gt;results[0].title&lt;/code&gt;, you are certain you found the song. You did not. The &lt;code&gt;disambiguation&lt;/code&gt; field says &lt;code&gt;live, 2001-06-13: KBCO Studio C, Boulder, CO, USA&lt;/code&gt;, and the release is a live radio-session compilation. Out of 146 matches, the highest-ranked "Yellow" by Coldplay is a bootleg of a radio appearance, not the studio single from Parachutes. A score of 100 means your query string matched this row. It says nothing about whether this row is the canonical recording. The only field that warns you is &lt;code&gt;disambiguation&lt;/code&gt;, and the happy path throws it away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; anything that needs stable IDs across services, past a gate that reads &lt;code&gt;score&lt;/code&gt; and &lt;code&gt;disambiguation&lt;/code&gt; before it trusts a match. Docs: &lt;a href="https://musicbrainz.org/doc/MusicBrainz_API" rel="noopener noreferrer"&gt;musicbrainz.org/doc/MusicBrainz_API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cover Art Archive: the hit is a 307 and the miss is a 404, never a plain 200
&lt;/h2&gt;

&lt;p&gt;Cover Art Archive serves album art keyed by a MusicBrainz release or release-group MBID. It is the sister project to MusicBrainz, keyless, and it has a contract that breaks naive status checks at both ends.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: -i shows the status line, no -L yet&lt;/span&gt;
curl &lt;span class="nt"&gt;-si&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"my-app/1.0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://coverartarchive.org/release-group/1b022e01-4da6-387b-8658-8678046e4cef"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 307
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a 307 redirect, not a 200. Follow it and you land on an archive.org JSON index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: -L follows the redirect&lt;/span&gt;
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"my-app/1.0"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} after %{num_redirects} redirects&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://coverartarchive.org/release-group/1b022e01-4da6-387b-8658-8678046e4cef"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200 after 2 redirects
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hit is a 307 that resolves, through archive.org, to a 200. A miss is blunt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://coverartarchive.org/release/00000000-0000-0000-0000-000000000000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the contract is redirect-or-404, never a plain 200 with image bytes. Code that does &lt;code&gt;if resp.status_code == 200&lt;/code&gt; before following redirects sees the hit as a "failure" (it is a 307) and needs a separate branch for the honest 404 on a miss. Let your HTTP client follow redirects, then check the final status, and treat 404 as "no art," not as an error to retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; fetching album covers by MBID, with redirect-following on and a 404 handled as an empty result. Docs: &lt;a href="https://coverartarchive.org/doc/API" rel="noopener noreferrer"&gt;coverartarchive.org/doc/API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. iTunes Search: it never says "no match," it fuzzy-matches your typo
&lt;/h2&gt;

&lt;p&gt;Apple's iTunes Search API is keyless, fast, and returns a &lt;code&gt;text/javascript&lt;/code&gt; body that is really JSON. The clean call is what you expect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://itunes.apple.com/search?term=radiohead&amp;amp;entity=song&amp;amp;limit=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"resultCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"trackId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1097861834&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artistName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Radiohead"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trackName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Let Down"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"collectionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"OK Computer"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Now feed it a keyboard mash that is not a band, an album, or a word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://itunes.apple.com/search?term=zzxqwlkjhgf&amp;amp;entity=song&amp;amp;limit=3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"resultCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"trackName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Zzzquil Commercial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artistName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Brave Rome Terrified"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"trackName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Waves (feat. Lil Zzzquil)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artistName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"TheNewErra"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"trackName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"H0URGLASS (feat. Lil Zzzquil)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artistName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"VibezyBaby"&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;I asked for &lt;code&gt;zzxqwlkjhgf&lt;/code&gt;. I got HTTP 200 and &lt;code&gt;resultCount: 3&lt;/code&gt;, three real tracks that share a few letters with my garbage. The API never returns "no match found." It fuzzy-matches, always, and reports success. So &lt;code&gt;resultCount &amp;gt; 0&lt;/code&gt; is not a signal that your query was meaningful; it is a signal that Apple found something loosely alphabetically nearby. Garbage in gives you garbage that looks found, at a confident 200. If you build autocompletion or enrichment on this, compare the returned &lt;code&gt;artistName&lt;/code&gt; against what you searched for before you trust the match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; quick catalog lookups and preview URLs, with a sanity check that the top result actually resembles the query. Docs: &lt;a href="https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/index.html" rel="noopener noreferrer"&gt;Apple iTunes Search API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Deezer: the 200 that carries an error object in its body
&lt;/h2&gt;

&lt;p&gt;Deezer's public API does search and lookup with no key. The useful part of a search hit is the ISRC, a real cross-catalog identifier you can carry to other services.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.deezer.com/search?q=radiohead&amp;amp;limit=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;138546811&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"All I Need"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"isrc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"GBSTK0700005"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;794527&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Now look up an artist ID that cannot exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://api.deezer.com/artist/999999999999"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.deezer.com/artist/999999999999"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 200
{"error":{"type":"DataException","message":"no data","code":800}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 200, and the payload is an error. &lt;code&gt;resp.raise_for_status()&lt;/code&gt; is delighted. The failure lives in &lt;code&gt;data["error"]&lt;/code&gt;, code 800, and nowhere in the status line. Deezer reports transport success and puts the application failure in the body, which is the exact opposite of a schema you can trust by shape alone: the success body and the error body are different objects under the same 200. Branch on whether &lt;code&gt;"error"&lt;/code&gt; is a key, not on the HTTP code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; getting ISRCs and preview URLs, with an explicit check for an &lt;code&gt;error&lt;/code&gt; key before you read &lt;code&gt;data&lt;/code&gt;. Docs: &lt;a href="https://developers.deezer.com/api" rel="noopener noreferrer"&gt;developers.deezer.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Lyrics.ovh: keyless and simple, and the flakiest host on this list
&lt;/h2&gt;

&lt;p&gt;Lyrics.ovh returns plain lyrics for an artist and title. No key, no envelope, just the text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.lyrics.ovh/v1/Coldplay/Yellow"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"lyrics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Look at the stars&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;look how they shine for you&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;and everything you do..."&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;It answered a clean 200 for me on July 12, 2026. I am going to be blunt about its one real weakness, because pretending otherwise would waste your afternoon: this host flaps. On other days I have watched the same endpoint time out or return a 500 with no pattern I could pin down. It is the least reliable host in this roundup. If you depend on it, wrap the call in a short timeout and a fallback to another lyrics source, and do not treat a single good curl as an uptime guarantee. I did not measure its uptime and I am not going to claim one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a fast, no-frills lyrics lookup for a hobby project, always behind a timeout and a fallback. Docs: &lt;a href="https://lyrics.ovh/" rel="noopener noreferrer"&gt;lyrics.ovh&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. lrclib: time-synced lyrics, and the search that ranks a B-side first
&lt;/h2&gt;

&lt;p&gt;lrclib is built for music players that need time-synced (karaoke-style) lyrics, and it is the source of the wrong song at the top of this post. Its &lt;code&gt;/api/search&lt;/code&gt; endpoint is a fuzzy search, and you already saw what it did with "yellow coldplay": HTTP 200, twenty results, &lt;code&gt;results[0]&lt;/code&gt; the B-side "Help Is Round the Corner." The fix is that lrclib also ships an exact-lookup endpoint, and you should almost always use that one instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: exact lookup by the four fields it needs&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://lrclib.net/api/get?track_name=Yellow&amp;amp;artist_name=Coldplay&amp;amp;album_name=Parachutes&amp;amp;duration=267"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/api/get&lt;/code&gt; takes the track name, artist name, album name, and duration, and resolves to the specific recording instead of ranking a pile of loose matches. Same API, same 200, completely different reliability: &lt;code&gt;/api/search&lt;/code&gt; is a guess machine you sort yourself, &lt;code&gt;/api/get&lt;/code&gt; is a lookup. If you have the album and rough duration (from a file's tags, say), skip the search entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; synced lyrics in a player, through &lt;code&gt;/api/get&lt;/code&gt; with the track's own metadata, falling back to &lt;code&gt;/api/search&lt;/code&gt; only when you must and then filtering the results yourself. Docs: &lt;a href="https://lrclib.net/docs" rel="noopener noreferrer"&gt;lrclib.net/docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Radio Browser: a directory that lists dead streams at 200
&lt;/h2&gt;

&lt;p&gt;Radio Browser is a community-run directory of internet radio stations. There is no single canonical host; you pick a mirror (&lt;code&gt;de1.&lt;/code&gt;, &lt;code&gt;nl1.&lt;/code&gt;, &lt;code&gt;all.&lt;/code&gt;) and query it keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: pick a mirror host&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://de1.api.radio-browser.info/json/stations/search?name=bbc&amp;amp;limit=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"stationuuid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"f7fd408e-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"BBC RADIO 1 - The biggest new pop..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"http://a.files.bbci.co.uk/ms6/live/..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lastcheckok"&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="w"&gt; &lt;/span&gt;&lt;span class="err"&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;Two things bite here. First, the mirror: there is no one hostname, so hardcode one and your app dies when that mirror does; the directory publishes a server list you are meant to rotate through. Second, and this is the important one, a station in the directory is not the same as a station that is streaming right now. The &lt;code&gt;lastcheckok&lt;/code&gt; field is the freshness flag: &lt;code&gt;1&lt;/code&gt; means the last health check reached the stream, &lt;code&gt;0&lt;/code&gt; means it did not. A 200 with &lt;code&gt;lastcheckok: 0&lt;/code&gt; is a dead stream that still shows up in your results with a full, valid record. Filter on &lt;code&gt;lastcheckok&lt;/code&gt; before you hand a URL to a player.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; building a radio picker, rotating across mirror hosts and filtering &lt;code&gt;lastcheckok: 1&lt;/code&gt;. Docs: &lt;a href="https://api.radio-browser.info/" rel="noopener noreferrer"&gt;api.radio-browser.info&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Open Opus: classical metadata that the pop APIs get wrong
&lt;/h2&gt;

&lt;p&gt;Classical music is where the mainstream catalog APIs fall apart, because they model a "song" and an "artist" and classical needs a composer, a performer, a work, and a movement. Open Opus models it properly, keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.openopus.org/composer/list/search/beethoven.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rows"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"composers"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"145"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"complete_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Ludwig van Beethoven"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"epoch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Early Romantic"&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;rows: 1&lt;/code&gt;, one composer, a clean ID. The limitation to know up front: Open Opus is a curated set of composers, not an exhaustive index of everyone who ever wrote a string quartet. A search for an obscure name returns &lt;code&gt;rows: 0&lt;/code&gt; inside a 200, not an error, so check the &lt;code&gt;rows&lt;/code&gt; count rather than assuming a hit. For the standard repertoire it is the cleanest free classical source I found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a classical browser or quiz where the composer coverage is the popular canon, checking &lt;code&gt;status.rows&lt;/code&gt; before you read &lt;code&gt;composers&lt;/code&gt;. Docs: &lt;a href="https://openopus.org/" rel="noopener noreferrer"&gt;openopus.org&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. ListenBrainz: open listening history you can read without a token
&lt;/h2&gt;

&lt;p&gt;ListenBrainz is MetaBrainz's open answer to Last.fm scrobbling: a public dataset of what people are listening to. Reading aggregate stats and public user listens needs no token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.listenbrainz.org/1/stats/sitewide/artists?range=week&amp;amp;count=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"artists"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"artist_mbid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0d79fe8e-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artist_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"BTS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"listen_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;819275&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Public user listens work the same way, keyless: &lt;code&gt;GET https://api.listenbrainz.org/1/user/rob/listens?count=1&lt;/code&gt; returned a real listen for me at 200. The honest split: reading public data is free and open, but submitting listens or reading a private user's data needs a token from a free account. So "keyless" is true for the read half and false for the write half; do not design a submitter and then discover the auth wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; charts, "top artists this week," and reading public scrobbles, with a token only if you need to write. Docs: &lt;a href="https://listenbrainz.readthedocs.io/" rel="noopener noreferrer"&gt;listenbrainz.readthedocs.io&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. AcousticBrainz: real audio features, from a dataset frozen in 2022
&lt;/h2&gt;

&lt;p&gt;AcousticBrainz serves computed audio features (loudness, BPM, key, mood estimates) per recording MBID, keyless. The data is real and detailed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://acousticbrainz.org/api/v1/96685213-a25c-4678-9a13-abd9ec81cf35/low-level"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"lowlevel"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"average_loudness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.570070445538&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"audio_properties"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;240.509384155&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="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;Here is the limitation that matters more than any field in that body: the AcousticBrainz project stopped collecting data in 2022 and the dataset is frozen. The API still answers a confident 200 with genuine features, but they are a snapshot that has not moved in years, and any recording that entered MusicBrainz after the freeze returns nothing. This is a freshness trap the status code will never show you: 200 forever, data stuck in 2022. Treat it as a historical dataset, not a live service, and expect misses on anything recent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; experiments and teaching on pre-2022 recordings, with eyes open that it is an archive. Docs and the shutdown notice: &lt;a href="https://acousticbrainz.org/" rel="noopener noreferrer"&gt;acousticbrainz.org&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Odesli / song.link: cross-platform links that change by country
&lt;/h2&gt;

&lt;p&gt;Odesli (the API behind song.link) takes one platform URL and returns the same track's links on the others: Spotify, Apple Music, YouTube, and more, keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.song.link/v1-alpha.1/links?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F4LRPiXqCikLlN15c3yImP7"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"entityUniqueId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SPOTIFY_SONG::4LRPiXqCikLlN15c3yImP7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"userCountry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"KZ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entitiesByUniqueId"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"As It Was"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"artistName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Harry Styles"&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;Look at &lt;code&gt;userCountry: "KZ"&lt;/code&gt;. I did not send a country. Odesli defaulted it to the country of my request IP, which resolved to Kazakhstan that day. This matters because the platform links and availability are geo-dependent: the same track can map to different catalog links, or be missing on a platform, depending on the country. A 200 in one region and a 200 in another can carry different bodies for the identical URL. If your users are not sitting where your server is, pass an explicit &lt;code&gt;userCountry&lt;/code&gt; parameter instead of letting it guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; "listen on your platform" buttons and cross-service matching, with &lt;code&gt;userCountry&lt;/code&gt; set explicitly for your audience. Docs: &lt;a href="https://odesli.co/" rel="noopener noreferrer"&gt;odesli.co&lt;/a&gt; (see the song.link API section).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not on the list: one shared key and five paywalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TheAudioDB&lt;/strong&gt; almost made the eleven. It returns rich artist data keyless-looking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://www.theaudiodb.com/api/v1/json/2/search.php?s=coldplay"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"artists"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"idArtist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"111239"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"strArtist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Coldplay"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"intFollowers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"57510550"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;2&lt;/code&gt; in the path is a shared demo key, not "no key." It works today, but it is a public credential the project can rotate or rate-limit at any time, and building on a shared test key is how a tutorial rots. I left it off the eleven and I am telling you why rather than quietly padding the count.&lt;/p&gt;

&lt;p&gt;And the names people expect that are simply not keyless in 2026: &lt;strong&gt;Last.fm&lt;/strong&gt; (API key), &lt;strong&gt;Spotify&lt;/strong&gt; (OAuth client credentials), &lt;strong&gt;Genius&lt;/strong&gt; (access token), &lt;strong&gt;Jamendo&lt;/strong&gt; (client ID), &lt;strong&gt;GetSongBPM&lt;/strong&gt; (key plus a backlink requirement). Good APIs, none of them "paste a URL into a terminal" free. If a 2021 tutorial told you Spotify was easy, it was easy before the app-registration step it skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does results[0] keep handing you the wrong song?
&lt;/h2&gt;

&lt;p&gt;Because every music search is a fuzzy ranking over a namespace with no primary key, and &lt;code&gt;results[0]&lt;/code&gt; is the top of a ranking, not the answer to a lookup. There is no unique ID for "Yellow by Coldplay." There is a studio single, a live radio session, a remaster, covers, and a B-side on the "Yellow" release, and the engine scores all of them against your string and returns the winner. A score of 100 means the string matched. It does not mean the row is the thing you meant. That gap, valid and non-empty and right-shaped and still wrong, is the whole post.&lt;/p&gt;

&lt;p&gt;I have paid for that exact failure class in a different domain. Across 2,190 production scraper runs, 962 of them on one Trustpilot scraper, the incidents that cost real money were almost never the crashes. A 500 pages you at 3am and you fix it. The response that came back valid, non-empty, and quietly attributed to the wrong company, because two businesses shared a name, does not page anyone. It corrupts a column, and you find out weeks later when a report looks off. Music APIs are that same disease with a friendlier face. (Honest note: I have not run a music resolver across those 2,190 runs. They are a scraping-and-enrichment domain, cited only as where the read-the-value habit comes from. Do not read 2,190 as a music-API number.)&lt;/p&gt;

&lt;p&gt;The fix is not another validation library. It is a field-sanity gate: never take &lt;code&gt;results[0]&lt;/code&gt; on faith, and reject the top hit when the body's own metadata says it is thin or ambiguous. Here is the shape of the gate I use, in music terms, against MusicBrainz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local: reject the top hit unless the body backs it up
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-app/1.0 (you@example.com)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# MusicBrainz 403s a default UA
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_recording&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://musicbrainz.org/ws/2/recording&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; AND artist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fmt&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;json&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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;headers&lt;/span&gt;&lt;span class="o"&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;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                 &lt;span class="c1"&gt;# a 200 sails through, so keep reading
&lt;/span&gt;    &lt;span class="n"&gt;recs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recordings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no match for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; by &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low score &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disambiguation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# e.g. "live, 2001-06-13: KBCO Studio C..."
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ambiguous top hit: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;disambiguation&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt; resolve_recording("Yellow", "Coldplay")
ValueError: ambiguous top hit: live, 2001-06-13: KBCO Studio C, Boulder, CO, USA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the real behavior against the live API on July 12, 2026. The function does not silently return the KBCO Studio C bootleg. It refuses, because the top hit carries a &lt;code&gt;disambiguation&lt;/code&gt; string that says "live." That is the entire move: read &lt;code&gt;score&lt;/code&gt; and &lt;code&gt;disambiguation&lt;/code&gt; on MusicBrainz, &lt;code&gt;resultCount&lt;/code&gt; and the returned &lt;code&gt;artistName&lt;/code&gt; on iTunes, the &lt;code&gt;error&lt;/code&gt; key on Deezer, &lt;code&gt;lastcheckok&lt;/code&gt; on Radio Browser, &lt;code&gt;userCountry&lt;/code&gt; on Odesli, the fields the tutorial's &lt;code&gt;results[0].name&lt;/code&gt; skips right over.&lt;/p&gt;

&lt;p&gt;There is no universal threshold. &lt;code&gt;min_score = 90&lt;/code&gt; is mine, for MusicBrainz, picked after watching which scores went with wrong entities; choose your own per API and write it down. And the name-collision problem is worse than one bootleg. A MusicBrainz search for &lt;code&gt;artist:Nirvana&lt;/code&gt; returned &lt;code&gt;count: 41&lt;/code&gt; the day I checked: forty-one distinct artists carrying the name Nirvana. If your resolver grabs the first one, you are right about the grunge band by luck, not by logic, and luck is not a data pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the best free music API with no key?&lt;/strong&gt;&lt;br&gt;
It depends on what you need, and several are keyless together. MusicBrainz is the backbone for metadata and stable IDs (send a real User-Agent). iTunes Search and Deezer are fast catalog search, and Deezer hands you ISRCs. lrclib gives time-synced lyrics. All returned HTTP 200 keyless on July 12, 2026. Pick by the job, and read each one's confidence fields before you trust &lt;code&gt;results[0]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the MusicBrainz API require an API key?&lt;/strong&gt;&lt;br&gt;
No key, but two conditions. You must send a descriptive &lt;code&gt;User-Agent&lt;/code&gt; header, because a default client UA gets a 403, and you must stay under about one request per second or you get rate-limited. Within those rules it is fully open and free, including the MBIDs that Cover Art Archive and AcousticBrainz key off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a music search return the wrong song at HTTP 200?&lt;/strong&gt;&lt;br&gt;
Because a search is a fuzzy ranking, not a lookup, and music has no single ID per song. For &lt;code&gt;q=yellow coldplay&lt;/code&gt;, lrclib returned HTTP 200 with a full list whose top result was the B-side "Help Is Round the Corner," and MusicBrainz ranked a live radio bootleg of "Yellow" first with a score of 100. The status is 200 and the body is well-formed; the signal that it is the wrong entity lives in fields like &lt;code&gt;score&lt;/code&gt; and &lt;code&gt;disambiguation&lt;/code&gt;, not in the HTTP code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a free lyrics API without a key?&lt;/strong&gt;&lt;br&gt;
Two, keyless. lyrics.ovh returns plain lyrics but its host flaps, so wrap it in a timeout and a fallback. lrclib returns time-synced lyrics; prefer its &lt;code&gt;/api/get&lt;/code&gt; exact lookup (track, artist, album, duration) over &lt;code&gt;/api/search&lt;/code&gt;, because the search ranks fuzzily and can put a B-side first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get audio features like BPM and key for free?&lt;/strong&gt;&lt;br&gt;
AcousticBrainz serves loudness, BPM, key, and mood estimates keyless by MusicBrainz recording ID, and returned a clean 200 for me on July 12, 2026. The catch is freshness: the project stopped collecting data in 2022, so the dataset is frozen and any recording added after the freeze returns nothing. Treat it as a historical archive, not a live service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I match one song across Spotify, Apple, and YouTube?&lt;/strong&gt;&lt;br&gt;
Odesli, the API behind song.link, takes one platform URL and returns the others keyless. One caveat: results are geo-dependent. Its &lt;code&gt;userCountry&lt;/code&gt; defaults to your request IP's country (mine resolved to KZ), and platform availability changes by country, so pass an explicit &lt;code&gt;userCountry&lt;/code&gt; for your audience instead of letting it guess.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every endpoint above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 12, 2026 before publishing; responses are trimmed, never reworded. I have not run a music resolver in production; the 2,190 runs are a scraping-and-enrichment domain, cited only as the origin of the read-the-value habit. I wrote up the same read-the-body pattern for &lt;a href="https://blog.spinov.online/blog/free-government-apis-no-key/" rel="noopener noreferrer"&gt;free government data APIs&lt;/a&gt; and &lt;a href="https://blog.spinov.online/blog/free-mock-fake-data-apis-no-key/" rel="noopener noreferrer"&gt;free mock and fake-data APIs&lt;/a&gt;. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless layer I verify. And tell me: what is the worst wrong-entity a "successful" API has handed you, the right shape with the wrong thing inside, and how long before you caught it? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>10 Free Facts, Jokes &amp; Name APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:45:45 +0000</pubDate>
      <link>https://dev.to/0012303/10-free-facts-jokes-name-apis-with-no-key-2026-2ao8</link>
      <guid>https://dev.to/0012303/10-free-facts-jokes-name-apis-with-no-key-2026-2ao8</guid>
      <description>&lt;p&gt;On July 12, 2026 I asked a free API to guess the age of someone named Xzqwlptv. It answered in a few milliseconds: HTTP 200, valid JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: no key needed&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.agify.io?name=Xzqwlptv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"count":0,"name":"Xzqwlptv","age":null}      # HTTP 200 OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Status 200. The JSON parses. The &lt;code&gt;age&lt;/code&gt; key is present, exactly where a schema says it belongs. Its value is &lt;code&gt;null&lt;/code&gt;. Every guard I usually reach for passes this response: &lt;code&gt;if resp.ok&lt;/code&gt;, &lt;code&gt;if "age" in data&lt;/code&gt;, even a JSON Schema that requires an &lt;code&gt;age&lt;/code&gt; property. The null walks straight past all of them and into the dataset.&lt;/p&gt;

&lt;p&gt;My earlier keyless-API posts kept circling one idea from different angles. HTTP 200 does not mean the read worked, because the body can be empty. HTTP 201 Created does not mean a write happened, because the read-back returns 404. This post moves the lie one level deeper than either of those. Here the status is 200, the body arrives, it parses, it matches your schema, and the field you came for is sitting right there. The value is just empty. &lt;strong&gt;The null that passes your schema check.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free fun or facts API&lt;/strong&gt; here means a public endpoint that returns a joke, a fact, or a guess about a name, with no API key, no signup, and no credit card. A URL you can paste into a terminal right now. Ten of them clear that bar, and I re-verified every response below with a live &lt;code&gt;curl&lt;/code&gt; on July 12, 2026: real HTTP code, real body, trimmed but never reworded.&lt;/p&gt;

&lt;p&gt;One scope note first, so the numbers stay honest. I curl-verified all ten APIs on July 12, 2026. I have &lt;strong&gt;not&lt;/strong&gt; run any of them in production. My 2,190 production scraper runs (962 of them on a single Trustpilot scraper) are a different domain, and I cite them for one reason only: they are why I read a field's value and its confidence instead of its status line. That number is not a claim about these ten endpoints.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it returns&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;The empty success to watch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;agify.io&lt;/td&gt;
&lt;td&gt;Age guess from a first name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.agify.io?name=Xzqwlptv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 with &lt;code&gt;age: null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;genderize.io&lt;/td&gt;
&lt;td&gt;Gender guess + probability&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.genderize.io?name=Andrea&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 with &lt;code&gt;gender&lt;/code&gt; at 0.69 confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;nationalize.io&lt;/td&gt;
&lt;td&gt;Country guesses for a name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.nationalize.io?name=Xzqwlptv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 with an empty &lt;code&gt;country&lt;/code&gt; array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;icanhazdadjoke&lt;/td&gt;
&lt;td&gt;Random dad joke&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET icanhazdadjoke.com/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 as HTML or JSON, your header decides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Advice Slip&lt;/td&gt;
&lt;td&gt;Random advice&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.adviceslip.com/advice/99999999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, envelope shape silently changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Useless Facts&lt;/td&gt;
&lt;td&gt;Random trivia fact&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET uselessfacts.jsph.pl/api/v2/facts/random?language=xx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, asked for &lt;code&gt;xx&lt;/code&gt;, got English&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Cat Facts&lt;/td&gt;
&lt;td&gt;Cat trivia + pagination&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET catfact.ninja/facts?limit=99999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, silently clamped to 1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;yesno.wtf&lt;/td&gt;
&lt;td&gt;Random yes / no + gif&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET yesno.wtf/api?force=yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200, a hidden flag decides "random"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Chuck Norris&lt;/td&gt;
&lt;td&gt;Random Chuck Norris joke&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.chucknorris.io/jokes/random&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None: an empty array here is normal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;JokeAPI v2&lt;/td&gt;
&lt;td&gt;Filtered jokes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET v2.jokeapi.dev/joke/Any&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None: it pairs its body flag with a real 400&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two APIs that a thousand tutorials still hardcode did not answer at all the day I checked. They get their own section after the ten.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. agify.io: the null that passes your schema check
&lt;/h2&gt;

&lt;p&gt;agify guesses a person's age from a first name, using a database of names it has seen with ages attached. It is keyless, fast, and it is the first API in a lot of "let's call an API" lessons. The happy path is clean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.agify.io?name=alex"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;426587&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"alex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;45&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;Age 45, and a &lt;code&gt;count&lt;/code&gt; of 426,587: the API saw the name "alex" attached to an age in 426,587 records. Now send it a name it has never seen, which is the transcript from the top of this post: &lt;code&gt;?name=Xzqwlptv&lt;/code&gt; returns HTTP 200 with &lt;code&gt;{"count":0,"name":"Xzqwlptv","age":null}&lt;/code&gt;. The &lt;code&gt;count&lt;/code&gt; drops to zero and &lt;code&gt;age&lt;/code&gt; becomes &lt;code&gt;null&lt;/code&gt;. The &lt;a href="https://agify.io/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; is upfront that an unknown name yields a null age; the point is that nothing in the HTTP layer flags it. Status 200, valid JSON, &lt;code&gt;age&lt;/code&gt; present. This guard, which I have shipped more than once, waves it through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# toy, illustrative: this "safety" check does not protect you
&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.agify.io&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# 200, and the key exists
&lt;/span&gt;    &lt;span class="n"&gt;ages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;    &lt;span class="c1"&gt;# None goes into the list
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a second, quieter trap in the same response, and it is the one I care about more. &lt;code&gt;count&lt;/code&gt; is the confidence, and almost nobody reads it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.agify.io?name=Kwabena"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;684&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Kwabena"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;45&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;"alex" is backed by 426,587 samples. "Kwabena" by 684. Both answered &lt;code&gt;age: 45&lt;/code&gt;. If your code reads only the &lt;code&gt;age&lt;/code&gt; field, a guess drawn from 684 people and a guess drawn from 426,587 look identical. One is a solid estimate. The other is a shrug wearing a number. The truthful guard reads the value and the confidence together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# toy, illustrative: the check the field actually needs
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;age&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;&lt;strong&gt;When to use it:&lt;/strong&gt; playful demos, and as the cheapest possible lesson that a present field is not an answered field. The threshold of 100 is mine, not the API's; pick your own and write it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. genderize.io: the confidence field you threw away
&lt;/h2&gt;

&lt;p&gt;genderize is agify's sibling from the same team: it guesses gender from a first name and, unlike agify, it labels its own uncertainty out loud.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.genderize.io?name=alex"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1668776&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"alex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"male"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.95&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;Unknown name, same empty success as agify, one field richer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.genderize.io?name=Xzqwlptv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Xzqwlptv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&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;gender: null&lt;/code&gt; and &lt;code&gt;probability: 0.0&lt;/code&gt;. The API is being honest to a fault here; it hands you a machine-readable "I have no idea" and most code still checks &lt;code&gt;if data["gender"]:&lt;/code&gt;, gets &lt;code&gt;None&lt;/code&gt;, and moves on quietly. But the sharper case is not the null. It is the answer that is real and thin at the same time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.genderize.io?name=Andrea"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1681597&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Andrea"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.69&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;gender: "female"&lt;/code&gt;, backed by 1.68 million records, at a probability of 0.69. That is barely better than a coin flip, and "Andrea" is male in Italy and female in much of the English-speaking world, which is exactly why the number is low. Your &lt;code&gt;if gender:&lt;/code&gt; passes on 0.69 the same way it passes on 0.95. The API did its job: it put the doubt in the payload. Reading it is on you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# toy, illustrative
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gender&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;genders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gender&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;&lt;strong&gt;When to use it:&lt;/strong&gt; rough demographic guesses where you will honor the &lt;code&gt;probability&lt;/code&gt;. Contrast it with agify in your head: agify buries its confidence in a &lt;code&gt;count&lt;/code&gt;, genderize spells it out as a probability, and both get ignored the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. nationalize.io: the empty array at HTTP 200
&lt;/h2&gt;

&lt;p&gt;nationalize, third in the same family, guesses which country a name comes from and returns a ranked list of candidates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.nationalize.io?name=alex"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1673580&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"alex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"country_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.095754&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"country_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"GB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.071241&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"country_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ES"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.052449&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;Note the top probability is 0.096. Even on a common name, the single best country guess is under 10 percent, which tells you how to weigh this data before you build anything on it. Now the unknown name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.nationalize.io?name=Xzqwlptv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Xzqwlptv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"country"&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;An empty array. Not &lt;code&gt;null&lt;/code&gt;, not an error, an empty list under a 200. This is the third form of "I don't know" from three sibling APIs on the same request: agify says &lt;code&gt;null&lt;/code&gt;, genderize says &lt;code&gt;null&lt;/code&gt; plus &lt;code&gt;0.0&lt;/code&gt;, nationalize says &lt;code&gt;[]&lt;/code&gt;. The failure mode is specific to the shape. This line looks defensive and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# IndexError on a 200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data["country"]&lt;/code&gt; exists, so a key check passes. The list is empty, so indexing into it throws, on a response your status check already blessed. There is no single "is this empty" test that covers &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;0.0&lt;/code&gt;, and &lt;code&gt;[]&lt;/code&gt;; you write one per shape, per API. That is the whole lesson of this trio.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; guessing likely origin for a name, always past a guard that treats an empty &lt;code&gt;country&lt;/code&gt; list as "no answer" rather than crashing on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. icanhazdadjoke: the same URL, HTML or JSON, your header decides
&lt;/h2&gt;

&lt;p&gt;icanhazdadjoke serves random dad jokes and it does something the previous three do not: it hands back a completely different body for the identical URL, depending on one request header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only: no Accept header&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://icanhazdadjoke.com/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset="utf-8"&amp;gt;
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is HTTP 200 with &lt;code&gt;Content-Type: text/html&lt;/code&gt;, a full web page, not a joke you can parse. Call &lt;code&gt;resp.json()&lt;/code&gt; on it and you get a decode error on a response your code just classified as a success. Add one header and the same URL turns into JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json"&lt;/span&gt; &lt;span class="s2"&gt;"https://icanhazdadjoke.com/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NCQfqHYgaFd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"joke"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"I'm glad I know sign language, it's pretty handy."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&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;Same path, same 200, HTML or JSON purely by what you put in &lt;code&gt;Accept&lt;/code&gt;. Forget the header and your parser chokes on a healthy response. There is a bonus tell in that JSON: &lt;code&gt;"status":200&lt;/code&gt;, the HTTP code copied down into the body. It is the same habit I documented in the &lt;a href="https://blog.spinov.online/blog/free-pop-culture-apis-no-key/" rel="noopener noreferrer"&gt;pop-culture APIs post&lt;/a&gt;, where Open Trivia DB tucks a &lt;code&gt;response_code&lt;/code&gt; inside the envelope. When an API repeats its status inside the payload, that is usually a hint that the payload is where the truth lives, not the status line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; any joke feature, with &lt;code&gt;Accept: application/json&lt;/code&gt; sent explicitly and never assumed by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Advice Slip: the envelope that changes shape at 200
&lt;/h2&gt;

&lt;p&gt;Advice Slip returns one piece of advice per call. The happy path is a tidy wrapper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.adviceslip.com/advice"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"slip"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;154&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"advice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"State the problem in words as clearly as possible."&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;You reach for &lt;code&gt;data["slip"]["advice"]&lt;/code&gt; and move on. Then someone requests an id that does not exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.adviceslip.com/advice/99999999"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Advice slip not found."&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still HTTP 200. Still &lt;code&gt;application/json&lt;/code&gt;. But the top-level key is no longer &lt;code&gt;slip&lt;/code&gt;, it is &lt;code&gt;message&lt;/code&gt;, and the shape underneath is different too. &lt;code&gt;data["slip"]&lt;/code&gt; throws a &lt;code&gt;KeyError&lt;/code&gt; on a response that reported success. This is schema drift inside a single endpoint: the envelope silently swaps from &lt;code&gt;{"slip": ...}&lt;/code&gt; to &lt;code&gt;{"message": ...}&lt;/code&gt; based on whether the lookup hit, and the status code stays 200 through the switch. Your parser has to branch on which key is present, not on the HTTP code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a random advice string for filler content, with a check for the &lt;code&gt;slip&lt;/code&gt; key before you read into it, because the miss path returns a valid 200 with a different structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Useless Facts: you asked for language xx, you got English
&lt;/h2&gt;

&lt;p&gt;Useless Facts serves a random trivia fact with a source attached. It also takes a &lt;code&gt;language&lt;/code&gt; parameter, and that parameter is where it gets quietly generous.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://uselessfacts.jsph.pl/api/v2/facts/random"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"b939f633..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Until 1994, world maps and globes sold in Albania only had Albania on them."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"djtech.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"permalink"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://uselessfacts.jsph.pl/api/v2/facts/b939f633..."&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;Now ask for a language that does not exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://uselessfacts.jsph.pl/api/v2/facts/random?language=xx"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"7e69818f..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"On a Canadian two dollar bill, the flag flying over the Parliament buildings is an American flag."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"djtech.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"permalink"&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked for language &lt;code&gt;xx&lt;/code&gt;. I got HTTP 200 and an English fact. No error, no warning, no 400. The only evidence that my request was ignored is the &lt;code&gt;language: "en"&lt;/code&gt; field in the response, which happens to disagree with what I sent. This is a silent fallback: the API decided that giving me something plausible beat telling me my input was wrong. If you localize content off a parameter like this and never compare the echoed &lt;code&gt;language&lt;/code&gt; back to the one you sent, you will ship English into a French page and every request will report success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a random fact for a footer or a loading screen, with a check that the returned &lt;code&gt;language&lt;/code&gt; matches what you requested if you care about the language at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Cat Facts: ask for 99,999, silently get 1,000
&lt;/h2&gt;

&lt;p&gt;Cat Facts is exactly what it sounds like, plus a paginated &lt;code&gt;/facts&lt;/code&gt; endpoint with a &lt;code&gt;limit&lt;/code&gt; parameter. The single-fact call is clean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://catfact.ninja/fact"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"fact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Unlike humans, cats are usually lefties. Studies indicate that their left paw is typically their dominant paw."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;110&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;Now abuse the pagination. Ask for far more than can exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://catfact.ninja/facts?limit=99999"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"current_page"&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="nl"&gt;"per_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;332&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"last_page"&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="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;332&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;facts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I requested &lt;code&gt;limit=99999&lt;/code&gt;. The response reports &lt;code&gt;per_page: 1000&lt;/code&gt;. The server clamped my limit to its own maximum of 1,000 and did it silently, HTTP 200, no field named "you asked for too much." Here the total is only 332 so I got everything anyway, but the clamp is the behavior that matters, and it is the same silent clamp I hit on Random User Generator in an earlier keyless-API post: you ask for N, the server caps at M, and your "full export" is a fraction of what you think. The tell is the &lt;code&gt;per_page&lt;/code&gt; field in the response, which reports what actually happened rather than what you asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; cat trivia, obviously, and as a cheap reminder to compare the &lt;code&gt;per_page&lt;/code&gt; and &lt;code&gt;total&lt;/code&gt; a paginator returns against the &lt;code&gt;limit&lt;/code&gt; you sent, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. yesno.wtf: the flag that decides whether "random" was random
&lt;/h2&gt;

&lt;p&gt;yesno.wtf answers a yes-or-no question at random and returns a matching gif. It is the simplest API on this list, and it still has a field that changes the meaning of the response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://yesno.wtf/api"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"forced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://yesno.wtf/assets/yes/11-a23cbde4....gif"&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;answer: "yes"&lt;/code&gt;, &lt;code&gt;forced: false&lt;/code&gt;. The &lt;code&gt;forced&lt;/code&gt; field says this answer was genuinely random. But the endpoint accepts a &lt;code&gt;?force=&lt;/code&gt; parameter, and when you use it the answer stops being random while the status stays 200:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://yesno.wtf/api?force=yes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"forced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://yesno.wtf/assets/yes/9-6403270c....gif"&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;Same 200, same &lt;code&gt;answer: "yes"&lt;/code&gt;, but &lt;code&gt;forced: true&lt;/code&gt;. If your code reads only &lt;code&gt;answer&lt;/code&gt;, a real coin flip and a rigged one are indistinguishable. The single field that tells them apart, &lt;code&gt;forced&lt;/code&gt;, is the one nobody bothers to read. It is a toy version of a real problem: a boolean deep in the payload that changes what the main value means, sitting quietly next to it at the same successful status.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a fun random decision, and a two-line demonstration for a junior of why you read the flags in a body, not just the headline field.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Chuck Norris: when an empty array is the correct answer
&lt;/h2&gt;

&lt;p&gt;The last two APIs on this list get it right, and they are here because "right" is worth seeing next to all of the above. api.chucknorris.io serves random Chuck Norris jokes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.chucknorris.io/jokes/random"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"categories"&lt;/span&gt;&lt;span class="p"&gt;:[],&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SPv79G_bQWuEBMXyCMHynA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Chuck Norris is the only person on earth who can rhyme 'orange' with 'lozenge'."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://api.chucknorris.io/jokes/random"&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;Look at &lt;code&gt;categories: []&lt;/code&gt;. An empty array, exactly the shape that meant "no answer" over on nationalize. Here it means the opposite: the joke simply has no category assigned, which is a perfectly normal state, and the &lt;code&gt;value&lt;/code&gt; field is fully populated. Same JSON shape, opposite meaning, and the only way to know is to read each API's contract. There is no universal rule that an empty array signals failure. Then request a category that does not exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.chucknorris.io/jokes/random?category=notreal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-11T21:39:17.715Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Not Found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"/jokes/random"&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;An actual HTTP 404, with a clean error object. When the request cannot be served, Chuck Norris says so with the right status code instead of a 200 and an empty body. That is the good-citizen behavior the first eight entries kept failing at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; random jokes with categories, and as the contrast case that teaches &lt;code&gt;[]&lt;/code&gt; is ambiguous across APIs and honest 404s still exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. JokeAPI v2: the body flag paired with the right status
&lt;/h2&gt;

&lt;p&gt;JokeAPI serves jokes with filters (categories, safe-mode, blacklists). Its normal response carries a status flag right in the body, which is the pattern that usually precedes a lie on this list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://v2.jokeapi.dev/joke/Any?safe-mode"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Pun"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"single"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"joke"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"I was struggling to figure out how lightning works, but then it struck me."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"flags"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"nsfw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"religious"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"..."&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;219&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"safe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"en"&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;error: false&lt;/code&gt;, embedded in the payload. On this list, a status field inside the body has been a warning sign. So I forced a failure, blacklisting every category so nothing could match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://v2.jokeapi.dev/joke/Any?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&amp;amp;contains=zzzzxxxqqq"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"internalError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;106&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"No matching joke found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"causedBy"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"No jokes were found that match your provided filter(s)."&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;Here is the part I did not expect until I ran it: that response came back &lt;strong&gt;HTTP 400&lt;/strong&gt;, not 200. JokeAPI puts &lt;code&gt;error: true&lt;/code&gt; in the body &lt;em&gt;and&lt;/em&gt; sets a 4xx status. Both channels agree. That is how the body-flag pattern is supposed to work, and it is why JokeAPI is the counterexample rather than another entry in the "200 lies" column. The analysis I started from guessed this endpoint returned a 200 with &lt;code&gt;error: true&lt;/code&gt;; my own curl on July 12 said 400, so the text you are reading follows the curl, not the guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; filtered jokes when you want categories and safe-mode, and as proof that an in-body status flag is fine as long as the HTTP code is telling the same story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two that stopped answering at all
&lt;/h2&gt;

&lt;p&gt;The dead teach the lesson louder. Both checks below are from July 12, 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quotable&lt;/strong&gt; (&lt;code&gt;api.quotable.io/random&lt;/code&gt;) was the default free quotes API in an enormous number of tutorials. It did not answer: connection failed, no HTTP status at all, on repeated tries.&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;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://api.quotable.io/random"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; curl: (couldn't connect), no HTTP status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bored API&lt;/strong&gt; (&lt;code&gt;boredapi.com/api/activity&lt;/code&gt;), the "suggest me something to do" API from a thousand beginner projects, did the same: no response, no status code.&lt;/p&gt;

&lt;p&gt;I make a narrow claim here: on the day and hour I checked, from where I checked, both were unreachable. That is enough to matter, because "keyless" and "alive" are properties you re-verify, not facts you remember. A tutorial written in 2022 against Quotable produces a dead link today, and the reader gets no HTTP status to even diagnose it.&lt;/p&gt;

&lt;p&gt;Two honesty notes on the flaky ones, because pretending my checks are the last word would be its own kind of lie. &lt;strong&gt;ZenQuotes&lt;/strong&gt; (&lt;code&gt;zenquotes.io/api/random&lt;/code&gt;) returned nothing when the analysis for this post was drafted, then answered me a clean 200 with a normal quote on July 12; it flaps, so I left it off the ten and I am not repeating the rate-limit gotcha I could not reproduce. &lt;strong&gt;Official Joke API&lt;/strong&gt; (&lt;code&gt;official-joke-api.appspot.com/random_joke&lt;/code&gt;) was reported dead in an earlier scan and answered me a healthy 200; a single curl from one machine is a snapshot, not an uptime measurement, and I did not measure uptime for anything here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a green schema check prove so little?
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable part, and the reason this is not a listicle. Every trap above survives the three checks most code actually runs. The status is 200, so &lt;code&gt;resp.raise_for_status()&lt;/code&gt; is happy. The body is valid JSON, so &lt;code&gt;resp.json()&lt;/code&gt; succeeds. The field you wanted is present, so a schema validator passes. And the value is still &lt;code&gt;null&lt;/code&gt;, or &lt;code&gt;[]&lt;/code&gt;, or a guess at 0.69 confidence, or an English fact you did not ask for. Line these up and they are not novelties, they are named classes of production drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;null&lt;/code&gt; scalar at 200&lt;/strong&gt; (agify). Your real backend returns &lt;code&gt;null&lt;/code&gt; for absent optional fields all the time. If your pipeline treats "field present" as "value present," it seeds nulls and reports success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A confidence field nobody reads&lt;/strong&gt; (agify's &lt;code&gt;count&lt;/code&gt;, genderize's &lt;code&gt;probability&lt;/code&gt;). Real ML and enrichment APIs attach confidence to every guess. Dropping it turns a 0.69 coin flip into a fact in your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty array at 200&lt;/strong&gt; (nationalize). Production search and recommendation endpoints return &lt;code&gt;[]&lt;/code&gt; for "no results" constantly, and &lt;code&gt;results[0]&lt;/code&gt; throws on a response that reported success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An envelope that changes shape&lt;/strong&gt; (Advice Slip). Real APIs return one shape on hit and another on miss under the same 200. Branch on the HTTP code and you read the wrong key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A silent fallback or clamp&lt;/strong&gt; (Useless Facts, Cat Facts). You ask for language &lt;code&gt;xx&lt;/code&gt; or &lt;code&gt;limit=99999&lt;/code&gt;; the server substitutes a default and reports success, and your export is quietly wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A schema check that passes against one of these has proven one thing: the response has the right &lt;em&gt;shape&lt;/em&gt;. Whether it has an &lt;em&gt;answer&lt;/em&gt; is a separate question, and the status line will not answer it for you. The fix is not more validation libraries. It is a guard that reads the value and its confidence, not just the field's presence. Against the flagship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local: a present field is not an answered field
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guess_age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.agify.io&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;# 200 sails through
&lt;/span&gt;    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&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="c1"&gt;# valid JSON, "age" key present
&lt;/span&gt;    &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;age&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no usable age for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;: age=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, count=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;guess_age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;# -&amp;gt; 45   (count 426,587)
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;guess_age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Xzqwlptv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; ValueError: no usable age for 'Xzqwlptv': age=None, count=0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;45
Traceback (most recent call last):
  ...
ValueError: no usable age for 'Xzqwlptv': age=None, count=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the live behavior from July 12, 2026 (traceback trimmed). &lt;code&gt;raise_for_status()&lt;/code&gt; is satisfied, the JSON parses, the &lt;code&gt;age&lt;/code&gt; key exists, and the extra two-line check catches the null and the zero-confidence case that all three earlier guards missed. The same skeleton covers the siblings: for genderize, gate on &lt;code&gt;probability&lt;/code&gt;; for nationalize, treat an empty &lt;code&gt;country&lt;/code&gt; list as "no answer" before you index into it.&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the read-the-value habit comes from there. The expensive incidents were never the 500s. A 500 pages you at 3am and you fix it. The quiet 200 with a &lt;code&gt;null&lt;/code&gt; where a value should be does not page anyone; it just corrupts a column, and you find out weeks later when a report looks wrong. These toy APIs are the cheapest lab I know for that exact failure, because you can reproduce it in one curl. I wrote the same pattern up for &lt;a href="https://blog.spinov.online/blog/free-pop-culture-apis-no-key/" rel="noopener noreferrer"&gt;pop-culture APIs&lt;/a&gt; and &lt;a href="https://blog.spinov.online/blog/free-government-apis-no-key/" rel="noopener noreferrer"&gt;government data APIs&lt;/a&gt;; the fun ones turned out to teach it best, because a joke API has nothing to hide behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is agify free without an API key?&lt;/strong&gt;&lt;br&gt;
Yes. &lt;code&gt;GET https://api.agify.io?name=alex&lt;/code&gt; works with no key, no signup, and no card, and returned HTTP 200 with &lt;code&gt;{"count":426587,"name":"alex","age":45}&lt;/code&gt; on July 12, 2026. There is a free daily request limit for anonymous use and paid tiers above it, but casual and learning use needs no credentials. The same holds for its siblings genderize.io and nationalize.io.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does agify return null for a name?&lt;/strong&gt;&lt;br&gt;
Because agify guesses age from names it has seen with ages attached, and an unfamiliar name has no data behind it. For &lt;code&gt;?name=Xzqwlptv&lt;/code&gt; it returns HTTP 200 with &lt;code&gt;{"count":0,"name":"Xzqwlptv","age":null}&lt;/code&gt;: &lt;code&gt;count&lt;/code&gt; is how many records backed the guess, and when it is 0 the &lt;code&gt;age&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;. The status is still 200 and the &lt;code&gt;age&lt;/code&gt; key is still present, so a schema check passes and the null enters your data unless you test the value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do count and probability mean in genderize and agify?&lt;/strong&gt;&lt;br&gt;
They are the confidence behind the guess. In agify, &lt;code&gt;count&lt;/code&gt; is the number of records the age estimate is based on; "alex" returned &lt;code&gt;count: 426587&lt;/code&gt;, a rare name might return &lt;code&gt;count: 684&lt;/code&gt;, and both can show the same &lt;code&gt;age&lt;/code&gt;. In genderize, &lt;code&gt;probability&lt;/code&gt; is how sure the gender guess is: "alex" came back at 0.95, "Andrea" at 0.69. A non-null answer at 0.69 is barely better than a coin flip, so gate on the confidence, not just on the field being present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does icanhazdadjoke return HTML instead of JSON?&lt;/strong&gt;&lt;br&gt;
Because it uses content negotiation. The same URL, &lt;code&gt;https://icanhazdadjoke.com/&lt;/code&gt;, returns a full HTML page under the default &lt;code&gt;Accept&lt;/code&gt; header and returns JSON only when you send &lt;code&gt;Accept: application/json&lt;/code&gt;. Both are HTTP 200. If you call &lt;code&gt;resp.json()&lt;/code&gt; without setting that header, it fails on a successful response. Send the header explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened to the Quotable and Bored APIs?&lt;/strong&gt;&lt;br&gt;
Both were unreachable when I checked on July 12, 2026: &lt;code&gt;api.quotable.io/random&lt;/code&gt; and &lt;code&gt;boredapi.com/api/activity&lt;/code&gt; returned no HTTP response at all on repeated tries from my machine. They were default APIs in years of tutorials, which means a lot of copy-pasted code now points at dead endpoints. That is a snapshot from one place and time, not an uptime claim, but it is why "keyless" is a property to re-verify rather than remember.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is a good free joke or facts API with no key?&lt;/strong&gt;&lt;br&gt;
For jokes, icanhazdadjoke (send &lt;code&gt;Accept: application/json&lt;/code&gt;), api.chucknorris.io, and v2.jokeapi.dev all worked keyless on July 12, 2026, and JokeAPI pairs its body error flag with a correct HTTP status. For facts, uselessfacts.jsph.pl and catfact.ninja both returned clean JSON. For name guesses, the agify / genderize / nationalize trio is the most useful, as long as you read the confidence fields rather than just the answer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every endpoint above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 12, 2026 before publishing; responses are trimmed, never paraphrased. I have not run these fun and facts APIs in production; the 2,190 runs are a different domain, cited only as the origin of the read-the-value habit. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless layer I verify. And tell me: what is the worst silent null or empty array you have had a "successful" API hand you, and how long did it take to notice? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>programming</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>9 Free Mock &amp; Fake-Data APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Sun, 12 Jul 2026 09:37:09 +0000</pubDate>
      <link>https://dev.to/0012303/9-free-mock-fake-data-apis-with-no-key-2026-145p</link>
      <guid>https://dev.to/0012303/9-free-mock-fake-data-apis-with-no-key-2026-145p</guid>
      <description>&lt;p&gt;On July 10, 2026 I sent JSONPlaceholder a POST. It answered 201 Created and handed me the new resource's id. Then I asked it for that resource.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, harmless: nothing is saved&lt;/span&gt;
curl &lt;span class="nt"&gt;-si&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://jsonplaceholder.typicode.com/posts &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"title":"hello"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 201
{
  "title": "hello",
  "id": 101
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-si&lt;/span&gt; https://jsonplaceholder.typicode.com/posts/101
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/2 404
{}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;201 Created, id 101. Then a 404 for id 101, four seconds later. The API refuted its own receipt. Nothing was ever written, and the docs are upfront about that; it is a mock. But your HTTP client cannot tell the difference, and neither can a test suite that stops reading at the status line.&lt;/p&gt;

&lt;p&gt;My earlier keyless-API posts kept hitting one lesson on the read path: HTTP 200 does not mean the read worked. The body can be empty, the entity can be wrong, the error can hide in a JSON field. Mock APIs move that lesson to the write path. &lt;strong&gt;A 201 Created is the server's claim that a write happened, not proof that it did.&lt;/strong&gt; The only proof of a write is a read.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free mock or fake-data API&lt;/strong&gt; here means a public endpoint that returns test data (fake users, fake products, or an echo of your own request) with no API key, no signup, and no credit card. A real URL you can paste into a terminal right now. Nine services clear that bar, and I re-verified every endpoint below with a live &lt;code&gt;curl&lt;/code&gt; on July 10, 2026: real HTTP code, real body, trimmed but never paraphrased.&lt;/p&gt;

&lt;p&gt;One scope note before the list, so the numbers stay honest. I curl-verified every API here on July 10, 2026; I have &lt;strong&gt;not&lt;/strong&gt; run these mock APIs in production. My 2,190 production scraper runs (962 of them on a single Trustpilot scraper) are a different domain, and I cite them for exactly one reason: they are why I read bodies instead of status lines. That number is not a claim about these nine endpoints.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it fakes&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;The lie to watch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;JSONPlaceholder&lt;/td&gt;
&lt;td&gt;Blog-style REST (posts, users, todos)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST jsonplaceholder.typicode.com/posts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;201 Created, nothing created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;DummyJSON&lt;/td&gt;
&lt;td&gt;Products, carts, users, quotes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE dummyjson.com/products/1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Deleted" with a timestamp, still there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Fake Store API&lt;/td&gt;
&lt;td&gt;E-commerce catalog&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET fakestoreapi.com/products/999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200 with a zero-byte body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Random User Generator&lt;/td&gt;
&lt;td&gt;Fake user profiles&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET randomuser.me/api/?results=5001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asks for 5,001, returns 1, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;httpbin&lt;/td&gt;
&lt;td&gt;Echo + any status code on demand&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET httpbin.org/status/404&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None: you order the failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;httpbingo&lt;/td&gt;
&lt;td&gt;httpbin rewritten in Go&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET httpbingo.org/get&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same path, different JSON types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Postman Echo&lt;/td&gt;
&lt;td&gt;Echo of your request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET postman-echo.com/get&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A third envelope shape&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Platzi Fake Store&lt;/td&gt;
&lt;td&gt;E-commerce with real CRUD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.escuelajs.co/api/v1/products/1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400 (not 404) for missing, shared DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Beeceptor Echo&lt;/td&gt;
&lt;td&gt;Echo of your request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET echo.free.beeceptor.com/probe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A fourth envelope shape&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One famous name is missing on purpose. reqres.in, the default fake API in years of tutorials, now answers with a 401 key wall. It gets its own section after the nine, along with three services that stopped answering at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. JSONPlaceholder: the 201 Created that created nothing
&lt;/h2&gt;

&lt;p&gt;JSONPlaceholder is the most famous fake REST API on the internet: 100 posts, 10 users, comments, todos, all keyless. It is the first API in half the frontend tutorials ever written. The read path is spotless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://jsonplaceholder.typicode.com/posts/1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"userId"&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;"id"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sunt aut facere repellat provident occaecati excepturi optio reprehenderit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quia et suscipit&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;suscipit recusandae consequuntur expedita et cum..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write path is the transcript at the top of this post. Every &lt;code&gt;POST /posts&lt;/code&gt; answers 201 with &lt;code&gt;id: 101&lt;/code&gt;, because the collection ships with 100 posts and your new one is never stored. The &lt;a href="https://jsonplaceholder.typicode.com/guide/" rel="noopener noreferrer"&gt;guide&lt;/a&gt; says it plainly: the resource "will not be really updated on the server but it will be faked as if."&lt;/p&gt;

&lt;p&gt;The miss contract has its own trap. Ask for a post that does not exist:&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;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://jsonplaceholder.typicode.com/posts/9999"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 404, content-type: application/json, body: {}   (2 bytes)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty object, not an error message. Code that does &lt;code&gt;resp.json().get("error")&lt;/code&gt; gets &lt;code&gt;None&lt;/code&gt; and stays silent. The 404 itself is honest; the body tells you nothing about why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; frontend prototypes and HTTP lessons, with the write path treated as theater. It is deterministic theater, which is exactly what you want for a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. DummyJSON: deleted at 23:52:01.722Z, still in stock
&lt;/h2&gt;

&lt;p&gt;DummyJSON serves a richer fake world: 100+ products with reviews and stock levels, plus users, carts, and quotes. The catalog detail is genuinely good.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://dummyjson.com/products/1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&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="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Essence Mascara Lash Princess"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"beauty"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;9.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"discountPercentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;10.48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;2.56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"stock"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;99&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;Now delete that product. The response is my favorite artifact in this whole list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, harmless: nothing is actually deleted&lt;/span&gt;
curl &lt;span class="nt"&gt;-sX&lt;/span&gt; DELETE &lt;span class="s2"&gt;"https://dummyjson.com/products/1"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 200, full product plus:&lt;/span&gt;
&lt;span class="c"&gt;#    "isDeleted": true, "deletedOn": "2026-07-10T23:52:01.722Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A deletion confirmation with a millisecond timestamp. Fetch &lt;code&gt;/products/1&lt;/code&gt; again and it is right back, HTTP 200, stock 99. The delete never happened; the API just generated paperwork that says it did. &lt;code&gt;POST /products/add&lt;/code&gt; plays the same game and answered me &lt;code&gt;201&lt;/code&gt; with &lt;code&gt;{"id":195,"title":"probe-47"}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Credit where due: the miss contract is the most honest of the three fake stores here. &lt;code&gt;GET /products/9999&lt;/code&gt; returns HTTP 404 with &lt;code&gt;{"message":"Product with id '9999' not found"}&lt;/code&gt;. Machine-readable, status and body in agreement. Docs at &lt;a href="https://dummyjson.com/docs" rel="noopener noreferrer"&gt;dummyjson.com/docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; the best-stocked fake catalog for UI work, and a teaching example of why a "deleted" confirmation means nothing until a read confirms it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Fake Store API: HTTP 200 with a zero-byte body
&lt;/h2&gt;

&lt;p&gt;Fake Store API is the standard fake e-commerce backend in React and Vue shop tutorials: 20 products, carts, users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://fakestoreapi.com/products/1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&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="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;109.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"men's clothing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;3.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;120&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;Ask for a product that does not exist and you get the sharpest read-path lie in this post:&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;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://fakestoreapi.com/products/999"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 200, content-type: application/json, body: 0 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero bytes. Not &lt;code&gt;null&lt;/code&gt;, not &lt;code&gt;{}&lt;/code&gt;, not an error object. The status says success and the declared content type says JSON, so &lt;code&gt;resp.json()&lt;/code&gt; throws on a response your code just classified as fine, and &lt;code&gt;if resp.ok:&lt;/code&gt; waves the emptiness straight into your pipeline. Writes are fake here too: my &lt;code&gt;POST /products&lt;/code&gt; with &lt;code&gt;"price": 1.0&lt;/code&gt; came back &lt;code&gt;201&lt;/code&gt; as &lt;code&gt;{"id":21,"title":"probe-47","price":1}&lt;/code&gt;. Note the price: &lt;code&gt;1.0&lt;/code&gt; went in, &lt;code&gt;1&lt;/code&gt; came out. On paper that is the same JSON number, but &lt;code&gt;json.loads&lt;/code&gt; now hands your test an &lt;code&gt;int&lt;/code&gt; where you posted a &lt;code&gt;float&lt;/code&gt;. Small, but if you round-trip money values through a mock like this, your assertions compare against a type you never sent. &lt;code&gt;DELETE /products/1&lt;/code&gt; returns 200 plus the full "deleted" product, which is still there. Docs at &lt;a href="https://fakestoreapi.com/docs" rel="noopener noreferrer"&gt;fakestoreapi.com/docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; quick shop UIs, with a guard that treats &lt;code&gt;200 + empty body&lt;/code&gt; as a failure, because this API will produce exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Random User Generator: ask for 5,001 users, get 1, no warning
&lt;/h2&gt;

&lt;p&gt;Random User Generator fabricates user profiles (names, addresses, avatars) for seeding databases and UI lists. Clean, fast, keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://randomuser.me/api/?results=1&amp;amp;inc=name,email&amp;amp;noinfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Mr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"first"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Vitaliano"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"last"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Lima"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"vitaliano.lima@example.com"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://randomuser.me/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; caps &lt;code&gt;results&lt;/code&gt; at 5,000 per request. Cross that line and watch what happens:&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://randomuser.me/api/?results=5001&amp;amp;inc=name&amp;amp;noinfo"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 200, len(results) == 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked for 5,001 records and received one. Not 5,000, the documented maximum. One. No error field, no warning, HTTP 200. Send garbage (&lt;code&gt;?results=abc&lt;/code&gt;) and you also get HTTP 200 with one result. The parameter validation is forgiving, which in practice means it lies: whatever you ask for, something plausible comes back.&lt;/p&gt;

&lt;p&gt;The guard is one line and it is the whole lesson:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;asked for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A seeding script without that line writes 1 row where it meant to write 5,001 and reports success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; seeding users for demos and load tests, always counting what came back against what you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. httpbin: the API where you order the failure yourself
&lt;/h2&gt;

&lt;p&gt;httpbin flips the premise of this whole list. The other APIs fake data; httpbin reflects your own request back so you can test the client you wrote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://httpbin.org/get?probe=47"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"probe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"47"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Accept"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"httpbin.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"85.198.89.58"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://httpbin.org/get?probe=47"&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;Note &lt;code&gt;args.probe&lt;/code&gt; is the string &lt;code&gt;"47"&lt;/code&gt;. Hold that thought for the next section.&lt;/p&gt;

&lt;p&gt;The killer feature is &lt;code&gt;/status/{code}&lt;/code&gt;: &lt;code&gt;GET https://httpbin.org/status/404&lt;/code&gt; returns an actual HTTP 404 (empty body) because you asked for one. After eight sections of APIs failing in ways nobody asked for, here is the one place you can order the failure on purpose and check that your retry logic, your error taxonomy, and your alerting actually fire. Most error-handling code I have read was never once executed before production. This is the free fix for that.&lt;/p&gt;

&lt;p&gt;The caveat is reliability. httpbin is a long-running community service, it has a reputation for slow spells under load, and there is no SLA behind it. It answered all my probes on July 10, 2026; I would still not put it inside a CI job I care about without a timeout. Self-hosting it is one &lt;code&gt;docker run&lt;/code&gt; away, per its &lt;a href="https://httpbin.org/" rel="noopener noreferrer"&gt;homepage&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; exercising your client's error path deliberately, which almost nobody does until an outage does it for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. httpbingo: the same request, different types
&lt;/h2&gt;

&lt;p&gt;httpbingo is a Go reimplementation of httpbin, hosted on Fly.io, and it exists because of the previous paragraph: people wanted a faster, steadier httpbin. Same paths, same idea.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://httpbingo.org/get?probe=47"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"probe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"47"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Accept"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"httpbingo.org"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"url"&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://httpbingo.org/get?probe=47"&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;Same &lt;code&gt;/get&lt;/code&gt;, same query. httpbin answered &lt;code&gt;"probe": "47"&lt;/code&gt;, a string. httpbingo answers &lt;code&gt;"probe": ["47"]&lt;/code&gt;, an array, and every header value is an array too. A client written against httpbin that does &lt;code&gt;data["args"]["probe"].upper()&lt;/code&gt; explodes on httpbingo with an &lt;code&gt;AttributeError&lt;/code&gt;, on a perfectly healthy HTTP 200.&lt;/p&gt;

&lt;p&gt;This is schema drift between two services that describe themselves as the same tool. Not an edge case, the default response shape. If "just point it at the mirror" ever sounded safe, this pair is the two-curl proof it is not. Docs at &lt;a href="https://httpbingo.org/" rel="noopener noreferrer"&gt;httpbingo.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; the steadier echo for regular use, as long as your parser was written for httpbingo and not merely pointed at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Postman Echo: three echo services, three envelopes
&lt;/h2&gt;

&lt;p&gt;Postman Echo is Postman's own echo service, the backend for their tutorials. Corporate hosting, been around for years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://postman-echo.com/get?probe=47"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"probe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"47"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"postman-echo.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"accept"&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="nl"&gt;"..."&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="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://postman-echo.com/get?probe=47"&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;Line up all three echoes I probed on July 10 and the point makes itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;httpbin:&lt;/strong&gt; &lt;code&gt;args&lt;/code&gt; values are strings, header keys are Title-Case (&lt;code&gt;"Accept"&lt;/code&gt;), and there is an &lt;code&gt;origin&lt;/code&gt; field with your IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;httpbingo:&lt;/strong&gt; &lt;code&gt;args&lt;/code&gt; and header values are all arrays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postman Echo:&lt;/strong&gt; &lt;code&gt;args&lt;/code&gt; values are strings again, but header keys are lower-case (&lt;code&gt;"accept"&lt;/code&gt;) and there is no &lt;code&gt;origin&lt;/code&gt; field at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this violates a spec, by the way. HTTP header names are case-insensitive on the wire, so each echo renders them into JSON however it likes. The spec does not care about the casing. Your parser does.&lt;/p&gt;

&lt;p&gt;Three services, one job, three different JSON envelopes for the identical request. Anyone who has moved between "compatible" providers in production has met this exact failure, except there it costs a weekend. Here it costs two curls, which is why I would show a junior this trio before any architecture diagram. Docs at &lt;a href="https://learning.postman.com/docs/developer/echo-api/" rel="noopener noreferrer"&gt;learning.postman.com/docs/developer/echo-api&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a stable echo with corporate hosting behind it, and the third data point that "drop-in replacement" is a myth even in mock infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Platzi Fake Store: real CRUD in a database everyone shares
&lt;/h2&gt;

&lt;p&gt;Platzi Fake Store API is the opposite bet from JSONPlaceholder: writes are real. POST actually inserts into a database, with pagination and filters on top. The catch is that it is one database, shared by every stranger on the internet, and it shows immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.escuelajs.co/api/v1/products?offset=0&amp;amp;limit=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"New Product 12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"new-product-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;100500&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="s2"&gt;"A description 12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Updated Category Name"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"https://placehold.co/600x400"&lt;/span&gt;&lt;span class="p"&gt;]}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is real production data from July 10, 2026: a product named "New Product 12" priced at 100,500, in a category someone renamed to "Updated Category Name". Now ask for product 1:&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;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://api.escuelajs.co/api/v1/products/1"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 400 (not 404!)&lt;/span&gt;
&lt;span class="c"&gt;# {"path":"/api/v1/products/1","timestamp":"2026-07-10T23:53:05.903Z",&lt;/span&gt;
&lt;span class="c"&gt;#  "name":"EntityNotFoundError",&lt;/span&gt;
&lt;span class="c"&gt;#  "message":"Could not find any entity of type \"Product\" matching: {...\"id\": 1...}"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lessons in one response. First, not-found arrives as &lt;strong&gt;400 Bad Request&lt;/strong&gt;, not 404, with a raw ORM error name and the query dump in &lt;code&gt;message&lt;/code&gt;. Your error handling, if it branches on 404 for missing entities, takes the wrong branch here. Second, why is product 1 missing at all? I cannot prove who did it, but the writes are real and the state is shared, so the safe assumption is that another user deleted it. The tutorial code you wrote on Monday fails on Tuesday when a stranger deletes "your" product.&lt;/p&gt;

&lt;p&gt;Put this next to JSONPlaceholder and you get the actual trade-off of mock backends: fake writes are deterministic but lie to you; real writes in a shared sandbox tell the truth and then someone else edits it. Pick your poison consciously. Docs at &lt;a href="https://fakeapi.platzi.com/" rel="noopener noreferrer"&gt;fakeapi.platzi.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; practicing real CRUD flows and pagination, in tests that assume nothing about existing ids, because those assumptions rot within days here.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Beeceptor Echo: the fourth envelope shape
&lt;/h2&gt;

&lt;p&gt;Beeceptor's free echo endpoint requires nothing: no key, no account. (Their actual product, a configurable mock server, sits behind a signup, so only the echo endpoint counts for this list.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://echo.free.beeceptor.com/probe?x=47"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"protocol"&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"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"echo.free.beeceptor.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/probe?x=47"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"echo.free.beeceptor.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Accept"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"parsedQueryParams"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"47"&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;Envelope number four. Here &lt;code&gt;path&lt;/code&gt; includes the query string as one raw lump (&lt;code&gt;"/probe?x=47"&lt;/code&gt;), the parsed parameters live in their own &lt;code&gt;parsedQueryParams&lt;/code&gt; object, and header keys keep their original case. Four echo services in this list, four incompatible answers to the same question: what request did I just send you? The free tier is rate limited; I did not push hard enough to measure the ceiling, so I will not quote one. Details at &lt;a href="https://beeceptor.com/" rel="noopener noreferrer"&gt;beeceptor.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a quick request inspector when httpbin is slow, coded against its own envelope like every other entry here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that grew a key wall: reqres.in
&lt;/h2&gt;

&lt;p&gt;For years, reqres.in was the answer to "what fake API do I hit in tutorials?". Thousands of blog posts and course exercises hardcode it. Here is what those exercises get today:&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;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://reqres.in/api/users/2"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 401&lt;/span&gt;
&lt;span class="c"&gt;# {"error":"missing_api_key",&lt;/span&gt;
&lt;span class="c"&gt;#  "message":"The x-api-key header is required for this endpoint.",&lt;/span&gt;
&lt;span class="c"&gt;#  "hint":"Create a free key at app.reqres.in and send it as x-api-key."}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is free and takes a minute to get, so this is a speed bump rather than a paywall. But copy-paste the code from any of those tutorials today and you get a 401 out of the box, and most of them will never be updated. That is why reqres is not one of my nine: "keyless" is a property you re-verify, not a fact you remember. I checked every entry above on July 10, 2026, and I would re-check them before trusting this post a year from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three that stopped answering at all
&lt;/h2&gt;

&lt;p&gt;The dead teach the same lesson louder. All three checks below are from July 10, 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;httpstat.us&lt;/strong&gt; was the beloved "give me any status code" service, the same job as &lt;code&gt;httpbin.org/status/{code}&lt;/code&gt;. It was on my shortlist for this post right up until the curl:&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;-si&lt;/span&gt; &lt;span class="s2"&gt;"https://httpstat.us/200"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; curl: (52) Empty reply from server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tries, both HTTP and HTTPS. The service whose one purpose was returning status codes now returns no status code at all, not even an error. If it comes back tomorrow, the point stands: it was gone the day I checked, and test suites all over GitHub still reference it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FakerAPI&lt;/strong&gt; (fakerapi.it) answered HTTP 502 from its own nginx on both probes that evening. I only have two probes a few minutes apart, so I make no claim about how long it has been down; on the day I checked, it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Random Data API&lt;/strong&gt; (random-data-api.com) did not answer at all: connection timeout after 12 seconds, twice.&lt;/p&gt;

&lt;p&gt;Two near-misses for completeness: &lt;strong&gt;mockapi.io&lt;/strong&gt; gives you endpoints only per project, behind a signup, so there is no public URL to verify. &lt;strong&gt;Mockaroo&lt;/strong&gt; requires an API key by design; I did not probe it and make no claim about its responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a green test against a mock prove so little?
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable part, and the reason this list is not just trivia. Mock APIs are the only APIs that lie honestly: the fake write is documented behavior, not a bug. Yet every mismatch cataloged above is a real class of drift between mock and production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;201 without a write&lt;/strong&gt; (JSONPlaceholder). Your real backend returns 201 after an actual insert. The mock returns 201 after nothing. A test asserting &lt;code&gt;status == 201&lt;/code&gt; passes identically against both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;200 with an empty body&lt;/strong&gt; (Fake Store API). Production APIs produce this shape too, during partial outages and bad deploys. If your parser never met it in testing, it meets it at 3am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A silent clamp&lt;/strong&gt; (Random User). Production pagination does this constantly: you ask for 500, the server caps at 100, and your "full export" is 20% of the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;400 where you expected 404&lt;/strong&gt; (Platzi). Error taxonomies differ per backend. Branch on the wrong code and your retry logic retries a request that can never succeed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Envelope drift between lookalikes&lt;/strong&gt; (httpbin vs httpbingo vs Postman Echo vs Beeceptor). The same drift that hits when you switch payment providers, geocoders, or LLM vendors that are "API-compatible".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A test suite that is green against a mock has proven one thing: your code can talk to the mock. The integration with the real backend is exactly as untested as it was before. The fix is not to abandon mocks; it is to stop letting the status line close the loop. A write is confirmed by a read, not by a receipt. In code, against the flagship liar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local -- a write is not done until a read confirms it
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;BASE&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://jsonplaceholder.typicode.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/posts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;              &lt;span class="c1"&gt;# 201 sails through this line
&lt;/span&gt;    &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;new_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# read back what the API claims it just created
&lt;/span&gt;    &lt;span class="n"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/posts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API said &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Created (id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;but read-back returned &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;

&lt;span class="nf"&gt;create_post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&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;hello&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Traceback (most recent call last):
  ...
RuntimeError: API said 201 Created (id=101) but read-back returned 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the live output from July 10, 2026 (traceback trimmed), not a mock-up of a mock. &lt;code&gt;raise_for_status()&lt;/code&gt; is satisfied, the JSON parses, the id looks plausible, and the read-back catches the lie in one extra request. Against DummyJSON the same skeleton catches the fake delete (read back after DELETE, expect a 404, get a 200). Against Fake Store API, add a &lt;code&gt;len(r.content) &amp;gt; 0&lt;/code&gt; check before parsing, because that is the one that hands you zero bytes under a 200.&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the read-back habit comes from there, where the expensive incidents were never the 500s. The 500s page you. The quiet 200s and 201s with wrong bodies just corrupt your data. I have written the same pattern for &lt;a href="https://blog.spinov.online/blog/free-food-nutrition-apis-no-key/" rel="noopener noreferrer"&gt;food and nutrition APIs&lt;/a&gt; and &lt;a href="https://blog.spinov.online/blog/free-pop-culture-apis-no-key/" rel="noopener noreferrer"&gt;pop-culture APIs&lt;/a&gt;; the mock world turned out to be the purest lab for it, because here the lying is the documented feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is JSONPlaceholder completely free?&lt;/strong&gt;&lt;br&gt;
Yes. No key, no signup, no card, and no request cap published for normal use. Reads are real JSON; writes (POST, PUT, DELETE) are simulated. The response pretends the operation succeeded, returns 201 with a plausible id, and stores nothing. The official guide states the resource "will not be really updated on the server but it will be faked as if."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does JSONPlaceholder return id 101 but not save my post?&lt;/strong&gt;&lt;br&gt;
The posts collection ships with exactly 100 items, and the API answers every create with the next id, 101, without persisting anything. That is by design: it gives frontend code a realistic 201 response to render. The proof is one request away: &lt;code&gt;GET /posts/101&lt;/code&gt; returns 404 immediately after the 201.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened to reqres.in?&lt;/strong&gt;&lt;br&gt;
It put its endpoints behind an API key. As of July 10, 2026, &lt;code&gt;GET https://reqres.in/api/users/2&lt;/code&gt; returns HTTP 401 with &lt;code&gt;"error":"missing_api_key"&lt;/code&gt; and a hint to create a free key at app.reqres.in. The key costs nothing, but every older tutorial that hardcodes reqres without a key now fails out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the best free alternative to reqres.in?&lt;/strong&gt;&lt;br&gt;
For keyless fake CRUD, JSONPlaceholder (deterministic, writes simulated) and DummyJSON (richer data, writes simulated, honest 404s on misses) are the closest matches. If you specifically need writes that persist, Platzi Fake Store API inserts into a real shared database, with the caveat that other users mutate the same data under you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Fake Store API actually save data?&lt;/strong&gt;&lt;br&gt;
No. &lt;code&gt;POST /products&lt;/code&gt; returns 201 with a new id (21 on my July 10, 2026 probe) but nothing is stored, and &lt;code&gt;DELETE /products/1&lt;/code&gt; returns 200 with the "deleted" product that remains available. Watch its miss contract too: a request for a nonexistent product returns HTTP 200 with a completely empty body, zero bytes, which breaks &lt;code&gt;resp.json()&lt;/code&gt; on a response that reports success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between httpbin and httpbingo?&lt;/strong&gt;&lt;br&gt;
httpbingo is a Go rewrite of httpbin hosted on Fly.io, generally steadier than the original. The response schema differs: httpbin returns query args and headers as plain strings, httpbingo wraps every value in an array (&lt;code&gt;"probe": "47"&lt;/code&gt; vs &lt;code&gt;"probe": ["47"]&lt;/code&gt;). Code written for one breaks on the other despite identical paths, so pin one and parse its exact shape.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every endpoint above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 10, 2026 before publishing; responses are trimmed, never paraphrased. I have not run these mock APIs in production; the 2,190 runs are a different domain, cited only as the origin of the read-back habit. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless layer I verify. And tell me: which mock burned you when the real backend arrived, and what did your green tests miss? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>testing</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>8 Free Pop-Culture APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Sat, 11 Jul 2026 03:42:56 +0000</pubDate>
      <link>https://dev.to/0012303/8-free-pop-culture-apis-with-no-key-2026-10ie</link>
      <guid>https://dev.to/0012303/8-free-pop-culture-apis-with-no-key-2026-10ie</guid>
      <description>&lt;p&gt;On July 10, 2026 I asked Open Trivia DB for fifty hard true/false questions from one category. It answered HTTP 200. My parser saw the green status, reached for &lt;code&gt;results&lt;/code&gt;, and got an empty list. Nothing threw. Nothing logged. The failure was sitting in a field that &lt;code&gt;if response.ok:&lt;/code&gt; never reads.&lt;/p&gt;

&lt;p&gt;Open Trivia DB, the volunteer-run trivia bank in half the quiz tutorials on the web, puts the real outcome in a &lt;code&gt;response_code&lt;/code&gt; field inside the JSON body. On that over-ask it returned &lt;code&gt;{"response_code":1,"results":[]}&lt;/code&gt;. Ask for an invalid &lt;code&gt;amount=0&lt;/code&gt; and it returns &lt;code&gt;{"response_code":2,"results":[]}&lt;/code&gt;. Both are HTTP 200. The transport says OK while the body says the request never worked.&lt;/p&gt;

&lt;p&gt;That is the whole post. Finding a free pop-culture API is the easy ten percent. The hard part: most of them hand you a clean 200 and something subtly wrong, and each one does it in a different way.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free pop-culture API&lt;/strong&gt; here means a public entertainment-data endpoint (trivia, TV, anime, games, films, cards) that answers with no API key, no signup, and no card. Not a paid vendor tier. Not a trial that expires. Not a portal that wants your email before it returns a byte. A real REST call you can paste into a terminal right now. I found eight that clear that bar and re-verified each with a live &lt;code&gt;curl&lt;/code&gt; on July 10, 2026: real HTTP code, real body, trimmed but never paraphrased. If you are building a Discord bot, a portfolio project, a demo, or teaching yourself HTTP, these are the endpoints you reach for. Every one of them can hand you a 200 that lies.&lt;/p&gt;

&lt;p&gt;Here is the pattern before the list. These are eight separate community projects, not one service wearing eight hats. A volunteer runs Open Trivia DB. TVMaze is its own company. Jikan is an unofficial proxy sitting in front of MyAnimeList. PokeAPI, Rick and Morty, SWAPI, and Studio Ghibli are each their own open-source project. Deck of Cards keeps server-side deck state. Eight projects means eight response shapes and eight ways to fail on a 200. What they share is not code. It is a habit: the status line says OK while the truth lives in the body.&lt;/p&gt;

&lt;p&gt;Let me be straight about scope, because this is where roundups usually stretch. I have &lt;strong&gt;not&lt;/strong&gt; run these pop-culture APIs in production. My numbers come from a different domain, 2,190 scraper runs including 962 on a single Trustpilot scraper, and I cite them for exactly one lesson those runs drilled into me: parse the payload, do not trust the status code. Every trimmed body below is from a live &lt;code&gt;curl&lt;/code&gt; on July 10, 2026, not from operating these services at scale. Where a keyless window looks shaky, I flag it instead of selling it.&lt;/p&gt;

&lt;p&gt;Here is the full set at a glance.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;No key?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Open Trivia DB&lt;/td&gt;
&lt;td&gt;Trivia questions by category&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET opentdb.com/api.php?amount=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;TVMaze&lt;/td&gt;
&lt;td&gt;TV show metadata and schedules&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.tvmaze.com/search/shows?q=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Jikan&lt;/td&gt;
&lt;td&gt;MyAnimeList anime and manga data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.jikan.moe/v4/anime?q=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;PokeAPI&lt;/td&gt;
&lt;td&gt;Pokémon universe data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET pokeapi.co/api/v2/pokemon/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Rick and Morty&lt;/td&gt;
&lt;td&gt;Characters, locations, episodes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET rickandmortyapi.com/api/character/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;SWAPI&lt;/td&gt;
&lt;td&gt;Star Wars people, films, ships&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET swapi.tech/api/people/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Studio Ghibli&lt;/td&gt;
&lt;td&gt;Ghibli film metadata&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET ghibliapi.vercel.app/films&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Deck of Cards&lt;/td&gt;
&lt;td&gt;Server-side shuffle and draw&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET deckofcardsapi.com/api/deck/new/draw/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two more names people search for, TMDB and OMDb, look keyless and are not. They get an honest section near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trivia: the status line is a receipt, not a promise
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Open Trivia DB: the 200 that lies in &lt;code&gt;response_code&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Open Trivia DB is a volunteer-maintained bank of trivia questions across categories like Entertainment, Science, and History. It shows up in a huge number of beginner quiz apps because the shape is simple and the data is fun. That simplicity hides the sharpest trap on this list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://opentdb.com/api.php?amount=1&amp;amp;type=multiple"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"response_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"General Knowledge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Foie gras is a French delicacy typically made from what part of a duck or goose?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"correct_answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Liver"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"incorrect_answers"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Heart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Stomach"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Intestines"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field that matters is &lt;code&gt;response_code&lt;/code&gt;, and &lt;code&gt;0&lt;/code&gt; means success. Now break it two ways. Both come back HTTP 200.&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://opentdb.com/api.php?amount=50&amp;amp;category=32&amp;amp;difficulty=hard&amp;amp;type=boolean"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; {"response_code":1,"results":[]}   asked for more than the pool holds&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://opentdb.com/api.php?amount=0"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; {"response_code":2,"results":[]}   invalid parameter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documented codes are &lt;code&gt;0&lt;/code&gt; ok, &lt;code&gt;1&lt;/code&gt; no results, &lt;code&gt;2&lt;/code&gt; bad parameter, &lt;code&gt;3&lt;/code&gt;/&lt;code&gt;4&lt;/code&gt; token errors, &lt;code&gt;5&lt;/code&gt; rate limited. Every one lives in the body, none in the HTTP status. And there is a second layer I reproduced by firing three requests back to back: Open Trivia DB rate-limits to about one request every five seconds per IP, and on the throttle it returns &lt;code&gt;{"response_code":5,"result":[]}&lt;/code&gt; with HTTP 429. Look closely at that body. The field renamed itself from &lt;code&gt;results&lt;/code&gt; to &lt;code&gt;result&lt;/code&gt;, singular. So a client that does &lt;code&gt;data["results"]&lt;/code&gt; throws a &lt;code&gt;KeyError&lt;/code&gt; on the exact path where it is already under load. Docs at &lt;a href="https://opentdb.com/api_config.php" rel="noopener noreferrer"&gt;opentdb.com/api_config.php&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; quiz apps and trivia prototypes, checking &lt;code&gt;response_code == 0&lt;/code&gt; before you touch &lt;code&gt;results&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TV and anime: fuzzy search and a proxy in the middle
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2. TVMaze: two endpoints on one host, two miss contracts
&lt;/h3&gt;

&lt;p&gt;TVMaze serves TV show metadata: air dates, network, status, and cross-IDs to IMDb and TVDB. Keyless, clean JSON, genuinely pleasant to work with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.tvmaze.com/search/shows?q=girls"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.89859617&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"show"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;139&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Girls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Scripted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Ended"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"HBO"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"externals"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"imdb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"tt1723816"&lt;/span&gt;&lt;span class="p"&gt;}}}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;score&lt;/code&gt; field is your first warning: &lt;code&gt;/search/shows&lt;/code&gt; is fuzzy and relevance-ranked, so &lt;code&gt;results[0]&lt;/code&gt; is the best guess, not necessarily the show you meant. A total miss returns HTTP 200 with an empty array &lt;code&gt;[]&lt;/code&gt;. Fine so far. The trap is the sibling endpoint. &lt;code&gt;/singlesearch/shows?q=zzzznotarealshow99&lt;/code&gt; returns HTTP 404 with the body &lt;code&gt;null&lt;/code&gt;. Same host, two different miss contracts: one endpoint answers a miss with &lt;code&gt;200 + []&lt;/code&gt;, the other with &lt;code&gt;404 + null&lt;/code&gt;. Code written against one blows up on the other. I confirmed both today. TVMaze is keyless with a documented per-IP rate limit, so stay polite in a loop. Docs at &lt;a href="https://www.tvmaze.com/api" rel="noopener noreferrer"&gt;tvmaze.com/api&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; TV metadata and schedules, reading &lt;code&gt;score&lt;/code&gt; and handling both the &lt;code&gt;200 + []&lt;/code&gt; and &lt;code&gt;404 + null&lt;/code&gt; miss shapes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Jikan: the pagination field you cannot ignore
&lt;/h3&gt;

&lt;p&gt;Jikan is an unofficial REST proxy in front of MyAnimeList, serving anime and manga metadata. Unofficial matters here, and it shows up in the failure mode.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.jikan.moe/v4/anime?q=naruto&amp;amp;limit=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"pagination"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"last_visible_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"has_next_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"count"&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="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"per_page"&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="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"mal_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://myanimelist.net/anime/20/Naruto"&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;Two things bite. First, Jikan is a proxy: when MyAnimeList itself is slow or down, Jikan returns 5xx errors you did not cause, and it enforces its own per-IP rate limit with a 429 under load (quiet in a solo test, painful in a loop). Second, look at &lt;code&gt;pagination.has_next_page: true&lt;/code&gt; and &lt;code&gt;last_visible_page: 30&lt;/code&gt;. Ignore those and you silently keep page 1 of a 30-page result and never notice the other 29. The success case is honest JSON. The way you lose data is by not reading the pagination it hands you. Docs at &lt;a href="https://docs.api.jikan.moe/" rel="noopener noreferrer"&gt;docs.api.jikan.moe&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; anime and manga metadata, honoring &lt;code&gt;has_next_page&lt;/code&gt; and adding backoff for the proxy's 429.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fictional universes: same idea, three different failure shapes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4. PokeAPI: the content-type flips on the error path
&lt;/h3&gt;

&lt;p&gt;PokeAPI is the canonical "first API" people learn on: the entire Pokémon universe as linked JSON. The happy path is spotless, which makes the miss path more surprising.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://pokeapi.co/api/v2/pokemon/ditto"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"abilities"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"ability"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"limber"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"is_hidden"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ability"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"imposter"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"is_hidden"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"base_experience"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now ask for a Pokémon that does not exist:&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://pokeapi.co/api/v2/pokemon/notarealmon"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 404, body: Not Found   (content-type: text/plain; charset=utf-8, 9 bytes)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The success response is &lt;code&gt;application/json&lt;/code&gt;. The miss response is &lt;code&gt;text/plain&lt;/code&gt;, nine bytes of &lt;code&gt;Not Found&lt;/code&gt;, not JSON at all. So &lt;code&gt;resp.json()&lt;/code&gt; on the error path throws a &lt;code&gt;JSONDecodeError&lt;/code&gt;, and the content type quietly switched under you between the hit and the miss. PokeAPI is keyless and asks you to cache aggressively because the data is effectively static. Docs at &lt;a href="https://pokeapi.co/docs/v2" rel="noopener noreferrer"&gt;pokeapi.co/docs/v2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; teaching HTTP or building a Pokédex demo, asserting the content type before you parse.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Rick and Morty: what a consistent error contract looks like
&lt;/h3&gt;

&lt;p&gt;This one is on the list to teach by contrast. Rick and Morty serves characters, locations, and episodes, and it is a favorite first API for good reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://rickandmortyapi.com/api/character/1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Rick Sanchez"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Alive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"species"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Human"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Earth (C-137)"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Citadel of Ricks"&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;Ask for a character that does not exist:&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://rickandmortyapi.com/api/character/9999"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 404, body: {"error":"Character not found"}   (application/json)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status code and the body agree. The error is machine-readable JSON with a clear message. That is what a consistent contract looks like, and it is rare enough that it is worth seeing next to the others. Hold this beside PokeAPI, which answers a 404 with &lt;code&gt;text/plain&lt;/code&gt;, and Open Trivia DB, which answers a broken request with &lt;code&gt;200&lt;/code&gt; and hides the code in the body. Same failure, three different levels of honesty. Docs at &lt;a href="https://rickandmortyapi.com/documentation" rel="noopener noreferrer"&gt;rickandmortyapi.com/documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a genuinely pleasant first API, and the reference example of a clean REST error contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. SWAPI: one name, two hosts, two incompatible envelopes
&lt;/h3&gt;

&lt;p&gt;SWAPI is the Star Wars API: people, films, planets, ships. The catch is that there are two live SWAPIs, and they do not agree on the shape of a response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.swapi.tech/api/people/1"&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://swapi.dev/api/people/1/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;swapi.tech&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Luke Skywalker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"172"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"77"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"created"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-09T23:57:35.805Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"edited"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-09T23:57:35.805Z"&lt;/span&gt;&lt;span class="p"&gt;}}}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;swapi.dev&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Luke Skywalker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"172"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"77"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"homeworld"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://swapi.dev/api/planets/1/"&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;swapi.dev&lt;/code&gt; returns the object flat, so &lt;code&gt;data["name"]&lt;/code&gt; works. &lt;code&gt;swapi.tech&lt;/code&gt; wraps the same data in &lt;code&gt;{"message":"ok","result":{"properties":{...}}}&lt;/code&gt;, so on that host the same access throws a &lt;code&gt;KeyError&lt;/code&gt;. One "SWAPI," two envelopes, and a client written for one silently fails on the other. There is a smaller tell hiding in the swapi.tech body. I pulled &lt;code&gt;people/1&lt;/code&gt; and &lt;code&gt;people/2&lt;/code&gt; and both carried the identical &lt;code&gt;created&lt;/code&gt; timestamp, &lt;code&gt;2026-07-09T23:57:35.805Z&lt;/code&gt;, down to the millisecond. That is a generation artifact, not the day Luke Skywalker's record changed, so do not read it as provenance. Both hosts are keyless; swapi.dev has had uptime wobble over the years, and swapi.tech is the more actively maintained fork. Docs at &lt;a href="https://swapi.tech/documentation" rel="noopener noreferrer"&gt;swapi.tech/documentation&lt;/a&gt; and &lt;a href="https://swapi.dev/documentation" rel="noopener noreferrer"&gt;swapi.dev/documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; Star Wars data, but pin one host and code to that host's exact envelope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Films and cards: fragile hosting and a happy-looking failure
&lt;/h2&gt;

&lt;h3&gt;
  
  
  7. Studio Ghibli: keyless is not the same as always up
&lt;/h3&gt;

&lt;p&gt;The Studio Ghibli API serves metadata for the films: titles, original titles, descriptions, running time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://ghibliapi.vercel.app/films?limit=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2baf70d1-42bb-4437-b551-e5fed5a87abe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Castle in the Sky"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"original_title"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"original_title_romanised"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Tenkū no shiro Rapyuta"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data is a clean 200. The risk is the hosting. This is a community-run project living on &lt;code&gt;vercel.app&lt;/code&gt; after it migrated off Heroku when the free dynos shut down in 2022. There is no SLA. A cold serverless function can time out, and the &lt;code&gt;vercel.app&lt;/code&gt; in the URL is itself a signal that the provider can move or disappear, the same way SWAPI drifted from a &lt;code&gt;.dev&lt;/code&gt; host to a &lt;code&gt;.tech&lt;/code&gt; fork. It answered me today. I would still put a timeout and a fallback around it. Docs at &lt;a href="https://ghibliapi.vercel.app/" rel="noopener noreferrer"&gt;ghibliapi.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a small, fun film dataset for a demo, wrapped in a timeout for the cold-start miss.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Deck of Cards: HTTP 200 with &lt;code&gt;success: false&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Deck of Cards is a tutorial classic: it holds a real shuffled deck server-side and lets you draw from it by &lt;code&gt;deck_id&lt;/code&gt;. It also carries the second-cleanest 200-lie on this list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://deckofcardsapi.com/api/deck/new/draw/?count=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"deck_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"jlj13d81p517"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"cards"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"7S"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"suit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SPADES"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"JS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"JACK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"suit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SPADES"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50&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;Now ask a fresh 52-card deck to draw 400 cards:&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://deckofcardsapi.com/api/deck/new/draw/?count=400"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 200, body: {"success": false, "deck_id":"...", "cards":[ ...52 cards... ]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 200, and &lt;code&gt;success: false&lt;/code&gt;. The operation could not do what you asked and the status line is perfectly happy about it. Then point it at a deck that does not exist:&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://deckofcardsapi.com/api/deck/notarealdeck/draw/?count=2"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 404, body: {"success": false, "error":"Deck ID does not exist."}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;success: false&lt;/code&gt; field, different HTTP code (404 this time). One API returns two different status codes for two failures, so the only field you can trust across both is &lt;code&gt;success&lt;/code&gt; in the body. Deck of Cards is keyless with no signup; the server tracks deck state by &lt;code&gt;deck_id&lt;/code&gt;. Docs at &lt;a href="https://deckofcardsapi.com/" rel="noopener noreferrer"&gt;deckofcardsapi.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; card games and shuffle-and-draw demos, branching on &lt;code&gt;success&lt;/code&gt;, never on the HTTP code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that look keyless and are not (TMDB, OMDb)
&lt;/h2&gt;

&lt;p&gt;Two names dominate "free movie API" searches, and neither is truly keyless. Both hand out a free key without a card, so they are a natural upgrade once the eight above run thin on films. Naming them keeps this honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TMDB&lt;/strong&gt; (The Movie Database) issues a free key by registration, no card. The keyless probe is instructive:&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://api.themoviedb.org/3/movie/550"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 401, body: {"status_code":7,"status_message":"Invalid API key: You must be granted a valid key.","success":false}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TMDB also drops &lt;code&gt;success:false&lt;/code&gt; into the body, same pattern as Deck of Cards, except here the HTTP status is an honest 401. It is the default for movie and TV data, and the free key is a two-minute signup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OMDb&lt;/strong&gt; issues a free key by email, 1,000 requests per day on the free tier.&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://www.omdbapi.com/?t=inception"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; HTTP 401, body: {"Response":"False","Error":"No API key provided."}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple IMDb-style movie metadata. Both are worth the signup when you need real film coverage, which the keyless eight do not give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: keyless is not the same as trustworthy
&lt;/h2&gt;

&lt;p&gt;"No key" tells you nothing about whether the thing is reliable, unlimited, or even alive. Four caveats, each from something above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyless is a snapshot, not a warranty.&lt;/strong&gt; NumbersAPI was the legendary free number-facts API in a thousand tutorials. Today, July 10, 2026, &lt;code&gt;http://numbersapi.com/42&lt;/code&gt; returns a &lt;code&gt;&amp;lt;!doctype html&amp;gt; ... &amp;lt;title&amp;gt;404 Not Found&amp;lt;/title&amp;gt;&lt;/code&gt; page instead of the plain-text fact its docs still promise. It quietly died while the documentation says it works. Nobody sent a key wall; the service just stopped. "Free and no key" said nothing about "still running next year."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyless is not unlimited.&lt;/strong&gt; Open Trivia DB caps you near one request every five seconds and returns a 429 with &lt;code&gt;response_code:5&lt;/code&gt;. Jikan throttles its proxy per IP. A demo that works fine when you run it once will 429 the moment you put it in a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Community hosts have no SLA.&lt;/strong&gt; Studio Ghibli runs on &lt;code&gt;vercel.app&lt;/code&gt;. SWAPI split from &lt;code&gt;.dev&lt;/code&gt; to a &lt;code&gt;.tech&lt;/code&gt; fork. The host in the URL is often the clearest hint of how fragile the provider is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The status line is a transport receipt.&lt;/strong&gt; Open Trivia DB (200 with &lt;code&gt;response_code&lt;/code&gt;), Deck of Cards (200 with &lt;code&gt;success:false&lt;/code&gt;), TVMaze (&lt;code&gt;404 + null&lt;/code&gt; versus &lt;code&gt;200 + []&lt;/code&gt;), PokeAPI (&lt;code&gt;text/plain&lt;/code&gt; on a 404). Four different ways a request can fail while HTTP looks fine or inconsistent. The body is the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the body, not the status line
&lt;/h2&gt;

&lt;p&gt;Finding the endpoint is ten percent of the work. Here is the ninety percent, pulled straight from the failures above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on a body field, not the HTTP code.&lt;/strong&gt; Open Trivia DB's &lt;code&gt;response_code&lt;/code&gt;, Deck of Cards' &lt;code&gt;success&lt;/code&gt;, TVMaze's empty array are all wrapped in a 200. The real outcome is a field in the JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the content-type before you parse.&lt;/strong&gt; PokeAPI hands you &lt;code&gt;text/plain&lt;/code&gt; on a 404. A blind &lt;code&gt;resp.json()&lt;/code&gt; throws, or a lazy &lt;code&gt;try/except&lt;/code&gt; swallows it and you index nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat the top fuzzy hit as a candidate.&lt;/strong&gt; TVMaze's &lt;code&gt;score&lt;/code&gt; and Jikan's search rank by relevance. &lt;code&gt;results[0]&lt;/code&gt; is a guess, not confirmation you found the right show.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin one host and one envelope.&lt;/strong&gt; SWAPI proves a single "API" can ship two incompatible shapes. Code to the exact host you tested against.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Respect the rate limit and the pagination.&lt;/strong&gt; One request every five seconds on Open Trivia DB, a 429 on Jikan, &lt;code&gt;has_next_page&lt;/code&gt; you have to follow. Keyless is not unlimited, and page 1 is not the whole result.&lt;/p&gt;

&lt;p&gt;Here is a guard that folds those checks into one function for Open Trivia DB, and the same skeleton drops onto the others by swapping the base URL and the body field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local -- trust the body field, not just the status code
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;opentdb_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://opentdb.com/api.php&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&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;my-quiz-app/1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;# honest 4xx/5xx (like the 429), not a 200 with a bad body
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&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="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected JSON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# PokeAPI-style text/plain stops here
&lt;/span&gt;    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# THE field an HTTP 200 hides
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                              &lt;span class="c1"&gt;# 1=no results, 2=bad param, 5=rate limited
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OpenTDB response_code=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;opentdb_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multiple&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;  &lt;span class="c1"&gt;# -&amp;gt; 1
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;opentdb_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                         &lt;span class="c1"&gt;# -&amp;gt; ValueError: response_code=2, not a silent []
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;raise_for_status&lt;/code&gt; catches the honest 429. The content-type line catches PokeAPI's &lt;code&gt;text/plain&lt;/code&gt;. The &lt;code&gt;response_code&lt;/code&gt; check catches the 200 that lies. Swap the base URL and the field and the same shape guards Deck of Cards (&lt;code&gt;success&lt;/code&gt;), TVMaze (empty array), and even Rick and Morty (which is already honest, so the guard mostly agrees with the status code).&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the output is always a table of rows that need trustworthy enrichment columns. &lt;a href="https://blog.spinov.online/blog/free-food-nutrition-apis-no-key/" rel="noopener noreferrer"&gt;Nutrition&lt;/a&gt; was one keyless column. &lt;a href="https://blog.spinov.online/blog/free-research-paper-apis-no-key/" rel="noopener noreferrer"&gt;Scholarly metadata&lt;/a&gt; was another. Pop-culture data is the fun one, but it fails the same way every enrichment source does: a clean 200 that is empty, the wrong entity, or not even the content type you expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are pop-culture APIs really free with no key?&lt;/strong&gt;&lt;br&gt;
Yes. Open Trivia DB, TVMaze, Jikan, PokeAPI, Rick and Morty, SWAPI, Studio Ghibli, and Deck of Cards all return data with no API key and no signup. For films specifically, TMDB and OMDb look keyless but require a free key (no card), so they are labeled honestly rather than counted in the eight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Open Trivia DB return an empty result with HTTP 200?&lt;/strong&gt;&lt;br&gt;
Because it reports success in a &lt;code&gt;response_code&lt;/code&gt; field in the JSON body, not in the HTTP status. &lt;code&gt;0&lt;/code&gt; means success, &lt;code&gt;1&lt;/code&gt; means no results for that query, &lt;code&gt;2&lt;/code&gt; means a bad parameter, and &lt;code&gt;5&lt;/code&gt; means rate limited. All of them come back HTTP 200 (except the 429 on rate limit), so a client checking only &lt;code&gt;response.ok&lt;/code&gt; reads an empty &lt;code&gt;results&lt;/code&gt; as success. Check &lt;code&gt;response_code == 0&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is a free API for movies and TV without a key?&lt;/strong&gt;&lt;br&gt;
For TV shows, TVMaze is fully keyless and returns air dates, networks, and cross-IDs to IMDb. For anime, Jikan (a MyAnimeList proxy) is keyless. For films, the honest answer is that TMDB and OMDb both need a free key, though no credit card, so they are a one-signup upgrade rather than truly keyless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does PokeAPI throw a JSON error on a missing Pokémon?&lt;/strong&gt;&lt;br&gt;
Because the miss path is not JSON. A successful lookup returns &lt;code&gt;application/json&lt;/code&gt;, but &lt;code&gt;GET /pokemon/notarealmon&lt;/code&gt; returns HTTP 404 with the body &lt;code&gt;Not Found&lt;/code&gt; as &lt;code&gt;text/plain&lt;/code&gt;. Calling &lt;code&gt;resp.json()&lt;/code&gt; on that throws a &lt;code&gt;JSONDecodeError&lt;/code&gt;. Assert the content type is JSON before you parse, and handle the 404 separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do these free APIs have rate limits?&lt;/strong&gt;&lt;br&gt;
Yes, keyless is not unlimited. Open Trivia DB caps you near one request every five seconds and returns HTTP 429 with &lt;code&gt;response_code:5&lt;/code&gt; (and quietly renames &lt;code&gt;results&lt;/code&gt; to &lt;code&gt;result&lt;/code&gt; on that error). Jikan enforces its own per-IP limit. TVMaze documents a per-IP rate limit too. For steady load, send a real User-Agent and add backoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which free API is best for learning to call an API?&lt;/strong&gt;&lt;br&gt;
PokeAPI and Rick and Morty are the friendliest to start with: clean JSON, good docs, and no key. Rick and Morty in particular has a consistent error contract (a 404 returns machine-readable JSON), which makes it a good example of how a REST API should behave, right up until you meet one that answers a broken request with a happy 200.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every API above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 10, 2026 before publishing; responses are trimmed, not paraphrased. I have not run these pop-culture APIs in production; the 2,190 runs are a different domain, cited only for the parse-the-body pattern. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless API layer I test. And tell me: which free pop-culture or entertainment API should I add to this list, and has one ever handed you a clean 200 with an empty or wrong body? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>8 Free Research Paper APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Fri, 10 Jul 2026 03:33:18 +0000</pubDate>
      <link>https://dev.to/0012303/8-free-research-paper-apis-with-no-key-2026-57k6</link>
      <guid>https://dev.to/0012303/8-free-research-paper-apis-with-no-key-2026-57k6</guid>
      <description>&lt;p&gt;On July 9, 2026 I asked arXiv for one paper about large language models. It answered HTTP 200. My parser took the response, found nothing useful, and moved on without raising a thing. The body was not corrupt. It just was not JSON.&lt;/p&gt;

&lt;p&gt;arXiv, the preprint server that carries most of the modern machine-learning literature, replies in Atom XML: &lt;code&gt;&amp;lt;?xml version="1.0"?&amp;gt;&amp;lt;feed&amp;gt;...&lt;/code&gt;. Run &lt;code&gt;json.loads()&lt;/code&gt; on that and you get a &lt;code&gt;JSONDecodeError&lt;/code&gt;, or, if the call sits inside a lazy &lt;code&gt;try/except&lt;/code&gt;, you get nothing and the code shrugs. Ten seconds of trusting the status line and an empty result goes straight into your index as if the paper never existed.&lt;/p&gt;

&lt;p&gt;That is the whole post. Finding a free research-paper API is the easy ten percent. The hard part: each one returns a clean 200 and something subtly wrong, and it does it a different way on almost every endpoint.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free research-paper API&lt;/strong&gt; here means a public scholarly-metadata endpoint (papers, DOIs, citations, open-access PDFs) that answers with no API key, no signup, and no card. Not a bulk data dump, not a partner agreement, not a portal that wants your institutional login. A real REST call you can paste into a terminal right now. I found eight that clear that bar. I re-verified each one with a live &lt;code&gt;curl&lt;/code&gt; on July 9, 2026: real HTTP code, real body, trimmed but never paraphrased. If you build a RAG index over papers, a citation tool, or an agent that reads the literature, these are the lookups you reach for. Every one of them can hand you a 200 that lies.&lt;/p&gt;

&lt;p&gt;Here is the finding before the list. This is not one engine wearing eight hats, which is what most keyless roundups turn out to be. These are eight separate organizations: Cornell runs arXiv, two nonprofit DOI registrars run Crossref and DataCite, OurResearch runs OpenAlex and Unpaywall, CERN runs Zenodo, and DOAJ and OpenCitations are their own nonprofits. That spread is the trap. Eight orgs means eight response shapes: Atom XML, a JSON:API bracket dialect, a bare array with no envelope, a Solr error body wearing a 200. You cannot write one parser and aim it at all of them. What they share is not code. It is a single failure mode: a 200 that is empty, fuzzy, or the wrong entity, almost never an honest 500.&lt;/p&gt;

&lt;p&gt;Let me be straight about scope, because this is where roundups usually stretch. Full disclosure: I have &lt;strong&gt;not&lt;/strong&gt; run these scholarly APIs in production. My numbers come from a different domain (2,190 scraper runs, including 962 on a single Trustpilot scraper), and I cite them for exactly one lesson those runs beat into me: parse the payload, do not trust the status code. Every trimmed body below is from a live &lt;code&gt;curl&lt;/code&gt; on July 9, 2026, not from operating these services at scale. Where a keyless window looks shaky, I flag it instead of selling it.&lt;/p&gt;

&lt;p&gt;Here is the full set at a glance.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;No key?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;arXiv&lt;/td&gt;
&lt;td&gt;Preprint search (physics, CS, math)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET export.arxiv.org/api/query?search_query=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Crossref&lt;/td&gt;
&lt;td&gt;DOI metadata for journal articles, books&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.crossref.org/works?query=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;OpenAlex&lt;/td&gt;
&lt;td&gt;Works, authors, institutions graph&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.openalex.org/works?search=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;DataCite&lt;/td&gt;
&lt;td&gt;DOIs for datasets, software, preprints&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.datacite.org/dois?query=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Unpaywall&lt;/td&gt;
&lt;td&gt;DOI to legal open-access PDF&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.unpaywall.org/v2/&amp;lt;doi&amp;gt;?email=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;DOAJ&lt;/td&gt;
&lt;td&gt;Articles in open-access journals&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET doaj.org/api/v3/search/articles/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;OpenCitations&lt;/td&gt;
&lt;td&gt;Citation graph by DOI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET opencitations.net/index/api/v2/references/doi:...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Zenodo&lt;/td&gt;
&lt;td&gt;CERN research-output repository&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET zenodo.org/api/records?q=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*Unpaywall needs an &lt;code&gt;email=&lt;/code&gt; parameter in the query. It is not a secret key, but it is not optional either: drop it and you get a 422, not a 200. Three more names people search for (Semantic Scholar, CORE, and HAL) look keyless and are not, and they get an honest section near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full-text search, where fuzzy is the real trap
&lt;/h2&gt;

&lt;p&gt;The first three are the ones you reach for to &lt;em&gt;find&lt;/em&gt; a paper by words. All three share the same quiet lie: the count looks like a hit, and the top row may not be your paper.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. arXiv: a 200 that is not even JSON
&lt;/h3&gt;

&lt;p&gt;arXiv is the preprint server for physics, math, computer science, and most of the ML field. Its API takes a &lt;code&gt;search_query&lt;/code&gt; and returns results as Atom XML, not JSON. That single fact is the most common way people break an arXiv integration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://export.arxiv.org/api/query?search_query=all:large+language+models&amp;amp;max_results=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;feed&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.w3.org/2005/Atom"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;ArXiv Query: search_query=all:large language models&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;opensearch:totalResults&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/opensearch:totalResults&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;entry&amp;gt;&amp;lt;title&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt; ... &lt;span class="nt"&gt;&amp;lt;/entry&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/feed&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 200, &lt;code&gt;content-type: application/atom+xml&lt;/code&gt;. There is no JSON mode. Parse it with &lt;code&gt;feedparser&lt;/code&gt; or an XML library, not &lt;code&gt;json.loads&lt;/code&gt;. The miss case is quieter still: search for something that matches nothing and arXiv returns another 200 with an empty &lt;code&gt;&amp;lt;feed&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;opensearch:totalResults&amp;gt;0&amp;lt;/opensearch:totalResults&amp;gt;&lt;/code&gt;. No entries, no error. The signal that you found something is &lt;code&gt;totalResults&lt;/code&gt;, not the HTTP code. One small transport trap: &lt;code&gt;http://export.arxiv.org&lt;/code&gt; answers &lt;code&gt;301&lt;/code&gt; to the &lt;code&gt;https://&lt;/code&gt; host, so start with &lt;code&gt;https&lt;/code&gt; or your first request is a redirect you have to follow. Docs at &lt;a href="https://info.arxiv.org/help/api/" rel="noopener noreferrer"&gt;info.arxiv.org/help/api&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; preprint discovery in physics, CS, and math, once your client speaks Atom XML and reads &lt;code&gt;totalResults&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Crossref: the count says millions, the top hit may not be yours
&lt;/h3&gt;

&lt;p&gt;Crossref is the nonprofit DOI registrar behind a large share of the world's journal articles and books. Its REST API is keyless and returns clean JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.crossref.org/works?query=large+language+models&amp;amp;rows=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"total-results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3142373&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"publisher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"The MIT Press"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;total-results&lt;/code&gt; reads 3,142,373 for that query on July 9, 2026, a live counter that ticks up while you read this, so treat the exact digits as a snapshot. It looks like Crossref nailed your paper. It did not necessarily. &lt;code&gt;query=&lt;/code&gt; is a fuzzy full-text match ranked by relevance, and &lt;code&gt;items[0]&lt;/code&gt; is the best &lt;em&gt;guess&lt;/em&gt;, not your specific work. If you paste in a title and grab the first item, you can silently attach the wrong DOI to a citation. Resolve the exact paper by DOI, or by matching precise fields and the returned &lt;code&gt;score&lt;/code&gt;, not by trusting position zero. A query that matches nothing returns a normal 200 with &lt;code&gt;total-results: 0&lt;/code&gt; and &lt;code&gt;items: []&lt;/code&gt;, again not an error. (An empty &lt;code&gt;query=&lt;/code&gt; does the opposite: it matches the whole ~184M-work corpus, so "typed nothing" and "found nothing" are not the same call.)&lt;/p&gt;

&lt;p&gt;There is a second, invisible cost. Crossref runs a &lt;strong&gt;polite pool&lt;/strong&gt; and an anonymous pool. Send a &lt;code&gt;mailto&lt;/code&gt; (in the query, or a descriptive User-Agent) and you land in the polite pool with more stable service. Skip it and you sit in the anonymous pool that gets throttled first when the service is busy. This is the same lesson MusicBrainz once taught me with a hard 403 on a missing User-Agent: identifying yourself is not politeness, it is data hygiene. Docs at &lt;a href="https://api.crossref.org/swagger-ui/index.html" rel="noopener noreferrer"&gt;api.crossref.org/swagger-ui&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; DOI and bibliographic metadata for published literature, with a &lt;code&gt;mailto&lt;/code&gt; and exact-match resolution instead of a blind &lt;code&gt;items[0]&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. OpenAlex: pagination that caps at 10,000 (with a loud 400)
&lt;/h3&gt;

&lt;p&gt;OpenAlex, from the nonprofit OurResearch, is the open replacement for Microsoft Academic Graph: works, authors, institutions, and venues, all linked. Keyless JSON, and it honors a polite pool via &lt;code&gt;mailto&lt;/code&gt; too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.openalex.org/works?search=large+language+models&amp;amp;per-page=1&amp;amp;mailto=you@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3780168&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"page"&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="nl"&gt;"per_page"&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="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;count&lt;/code&gt; is 3,780,168 for that search on July 9. Here is the trap that bites at scale: classic &lt;code&gt;page=&lt;/code&gt; pagination is capped at 10,000 records. Ask for page 201 at 50 per page and you get an HTTP 400 with &lt;code&gt;Maximum results size of 10,000 records is exceeded. Cursor pagination is required&lt;/code&gt;. Credit where it is due, that is an honest error, not a lying 200, the one spot on this list where the API fails loudly instead of quietly. To walk a full result set you switch to cursor pagination (&lt;code&gt;cursor=*&lt;/code&gt;, then follow &lt;code&gt;meta.next_cursor&lt;/code&gt;). The &lt;code&gt;search&lt;/code&gt; parameter is full-text and fuzzy, same caveat as Crossref: the top result is a candidate, not a confirmed match. And, again, no &lt;code&gt;mailto&lt;/code&gt; means the anonymous pool and rougher throttling. Docs at &lt;a href="https://docs.openalex.org/" rel="noopener noreferrer"&gt;docs.openalex.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; building a citation or author graph, with cursor pagination and a &lt;code&gt;mailto&lt;/code&gt;, not &lt;code&gt;page=&lt;/code&gt; past 10k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Datasets, open-access PDFs, and OA journals
&lt;/h2&gt;

&lt;p&gt;The next three narrow from "find any paper" to specific jobs: a dataset DOI, a free PDF, a vetted open-access article. The trap shifts from fuzzy matching to coverage: an empty result means "we do not index this," not "it does not exist."&lt;/p&gt;

&lt;h3&gt;
  
  
  4. DataCite: the bracket parameter that breaks your URL builder
&lt;/h3&gt;

&lt;p&gt;DataCite is the other big nonprofit DOI registrar. Its DOIs point at datasets, software, and preprints rather than journal articles. Keyless JSON, JSON:API style.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only (note the encoded brackets)&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.datacite.org/dois?query=climate&amp;amp;page%5Bsize%5D=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"10.5281/zenodo.21271391"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"dois"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is fine. Getting there is the trap. DataCite uses JSON:API bracket parameters like &lt;code&gt;page[size]&lt;/code&gt;, and a raw &lt;code&gt;[&lt;/code&gt; in a URL breaks naive builders. curl itself refuses it with &lt;code&gt;curl: (3) bad range in URL position ...&lt;/code&gt;. You have to URL-encode the brackets as &lt;code&gt;%5B%5D&lt;/code&gt;, so &lt;code&gt;page[size]=1&lt;/code&gt; becomes &lt;code&gt;page%5Bsize%5D=1&lt;/code&gt;. Skip that and your request never leaves the client, or your HTTP library mangles it. On a miss the response is a plain 200 with &lt;code&gt;data: []&lt;/code&gt;, which brings up the coverage point that runs through this whole middle section: an empty &lt;code&gt;data&lt;/code&gt; array means DataCite has not indexed it, not that the thing does not exist. Docs at &lt;a href="https://support.datacite.org/docs/api" rel="noopener noreferrer"&gt;support.datacite.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; finding DOIs for datasets and software, with the bracket params URL-encoded.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Unpaywall: a 200 with &lt;code&gt;best_oa_location: null&lt;/code&gt; is not a free PDF
&lt;/h3&gt;

&lt;p&gt;Unpaywall (also OurResearch) does one job: given a DOI, it tells you whether a legal open-access copy exists and where. It is keyless, but it requires an &lt;code&gt;email&lt;/code&gt; parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only -- swap in YOUR real email; example.com is rejected with a 422&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.unpaywall.org/v2/10.1038/nature12373?email=you@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"best_oa_location"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"host_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"repository"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://arxiv.org/pdf/1304.106..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"is_oa"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two traps. First, the &lt;code&gt;email&lt;/code&gt; is not optional: drop it and you get a 422, not a 200, and a throwaway like &lt;code&gt;example.com&lt;/code&gt; is rejected with a 422 too, so it has to be a real address. This is "keyless" only in the sense that the email is not a secret. Second, and quieter: a paywalled article returns a perfectly valid 200 with &lt;code&gt;"best_oa_location": null&lt;/code&gt;. The request succeeded; there is simply no free PDF. Code that does &lt;code&gt;resp.json()["best_oa_location"]["url"]&lt;/code&gt; throws a &lt;code&gt;TypeError&lt;/code&gt; on &lt;code&gt;null&lt;/code&gt;, on a 200. Check &lt;code&gt;is_oa&lt;/code&gt; and the null before you dereference. One honest note: Unpaywall is now in maintenance, and OurResearch steers new integrations to OpenAlex, which absorbed its open-access data. It still answers today; I would not build something new solely on it. Docs at &lt;a href="https://unpaywall.org/products/api" rel="noopener noreferrer"&gt;unpaywall.org/products/api&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; turning a DOI into an open-access PDF link, with a null guard and OpenAlex as the longer-term home.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. DOAJ: &lt;code&gt;total: 0&lt;/code&gt; means "not in an OA journal we index," not "no such paper"
&lt;/h3&gt;

&lt;p&gt;DOAJ, the Directory of Open Access Journals, indexes articles, but only from journals it has vetted as fully open access. Keyless v3 JSON API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://doaj.org/api/v3/search/articles/machine%20learning?pageSize=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;189602&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"page"&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="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"bibjson"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"10.46481/..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"doi"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]}}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;total&lt;/code&gt; is 189,602 for that query on July 9, a live count that drifts run to run, so read the exact number as a July 9 snapshot. The trap is the shape of the corpus, not the response. DOAJ covers only declared open-access journals, so a &lt;code&gt;total: 0&lt;/code&gt; is a coverage gap, not proof the paper does not exist. The paper may sit in a hybrid or closed journal DOAJ deliberately does not index. Use DOAJ to confirm something &lt;em&gt;is&lt;/em&gt; in a vetted OA venue; do not use an empty result to conclude a work is missing from the literature. Docs at &lt;a href="https://doaj.org/api/v3/docs" rel="noopener noreferrer"&gt;doaj.org/api/v3/docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; filtering for genuinely open-access journal articles, never as a completeness check on all literature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Citation graphs and research repositories
&lt;/h2&gt;

&lt;p&gt;The last two are less about finding a paper and more about what surrounds it: who cites it, and what artifacts were deposited. Same 200-that-lies theme, two new shapes.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. OpenCitations: an empty array is "not indexed," and every ID is four IDs
&lt;/h3&gt;

&lt;p&gt;OpenCitations is a nonprofit that publishes open citation data (who cites whom, keyed by DOI). Its v2 API returns a bare JSON array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.opencitations.net/index/v2/references/doi:10.1186/s13643-016-0384-4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"oci"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"061402590389-061901516871"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"omid:br/... doi:10.1186/... openalex:W2560438049 pmid:27919275"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cited"&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, an empty &lt;code&gt;[]&lt;/code&gt; means OpenCitations has not indexed those citations, not that the paper has zero citations. Coverage again, dressed as absence. Second, look at that &lt;code&gt;citing&lt;/code&gt; value: it is a single space-separated string carrying four identifiers at once, an OMID, a DOI, an OpenAlex ID, and a PubMed ID, all for the &lt;em&gt;same&lt;/em&gt; work. That is the entity-resolution problem made literal. Split on space and treat each token as a separate paper and you have quadrupled your node count with phantom duplicates. Parse the prefixes (&lt;code&gt;omid:&lt;/code&gt;, &lt;code&gt;doi:&lt;/code&gt;, &lt;code&gt;openalex:&lt;/code&gt;, &lt;code&gt;pmid:&lt;/code&gt;) and collapse them to one entity. Small transport note: the older &lt;code&gt;opencitations.net/index/api/v2/...&lt;/code&gt; path 301-redirects to the canonical host &lt;code&gt;api.opencitations.net/index/v2/...&lt;/code&gt; (the one above), and the &lt;code&gt;http://&lt;/code&gt; form 301s too, so call the canonical host directly or let your client follow redirects. Docs at &lt;a href="https://opencitations.net/index/api/v2" rel="noopener noreferrer"&gt;opencitations.net/index/api/v2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; open citation-graph edges by DOI, with prefix-aware ID parsing and empty-array-means-uncovered handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Zenodo: same paper, many versions, one concept
&lt;/h3&gt;

&lt;p&gt;Zenodo is CERN's general-purpose repository for research outputs: datasets, software, posters, and papers, each with a minted DOI. Keyless JSON search.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://zenodo.org/api/records?q=machine+learning&amp;amp;size=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;13235113&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"conceptrecid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"13235112"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"doi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"10.5281/zenodo.13235113"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the double-nested &lt;code&gt;hits.hits&lt;/code&gt; envelope: a Solr/Elasticsearch tell, and a different shape from every other entry on this list, which is the whole diversity problem in one field path. Two real traps. Records are versioned: one logical upload has many &lt;code&gt;id&lt;/code&gt; values that all share a single &lt;code&gt;conceptrecid&lt;/code&gt;. If you index search results without collapsing on &lt;code&gt;conceptrecid&lt;/code&gt;, the same dataset shows up three times in your top-k as if the versions were different works. And Zenodo rate-limits harder than it looks: the live response carries &lt;code&gt;x-ratelimit-limit: 30&lt;/code&gt; with &lt;code&gt;retry-after: 60&lt;/code&gt;, so anonymous clients get about 30 requests a minute before a 429. Keyless is not the same as unlimited. Docs at &lt;a href="https://developers.zenodo.org/" rel="noopener noreferrer"&gt;developers.zenodo.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; searching research artifacts and software, deduplicated by &lt;code&gt;conceptrecid&lt;/code&gt;, with backoff for the 429.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three that look keyless and are not (and one honorable mention)
&lt;/h2&gt;

&lt;p&gt;Three APIs kept surfacing when I searched for "free research paper API," and all three lie about being keyless in a different way. Naming them is part of keeping this honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic Scholar&lt;/strong&gt; (from the Allen Institute for AI) has a good graph API and a keyless "shared pool." On July 9 that pool answered me with a 429 on the first request:&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://api.semanticscholar.org/graph/v1/paper/search?query=large+language+models&amp;amp;limit=1&amp;amp;fields=title,year"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Too Many Requests. ... apply for a key ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"429"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;"code":"429"&lt;/code&gt; sits in the JSON body while the transport is also 429, so at least it is consistent. But the shared keyless pool is congested enough that you cannot rely on it. For anything real, request a free key and add backoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CORE&lt;/strong&gt; (Open University, UK) aggregates open-access full text and, on July 9, actually returned a keyless 200 after a &lt;code&gt;301&lt;/code&gt; to a trailing slash:&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="nl"&gt;"totalHits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;592803&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"limit"&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="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"authors"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"van Rijn J.N."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;totalHits&lt;/code&gt; was 592,803, so it works keyless today. But CORE's own docs ask for a Bearer key and it throttles hard, so the keyless window is a courtesy that can close without notice. Do not build on it. (For the record: the "301" here is a redirect to a trailing slash, not a key wall. Worth knowing before you assume you are being blocked.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HAL&lt;/strong&gt; (the French national open archive) is the cleanest example of "200 lies" I hit all day. Every form of the query I tried, from our datacenter IP, returned HTTP 200 with an error body:&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://api.archives-ouvertes.fr/search/?q=deep&amp;amp;rows=1&amp;amp;wt=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Error. See help : /docs"&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;HTTP 200, and the body is nothing but an error object. I tried three query shapes and got the same error each time, which smells like an API change or an IP filter on datacenter ranges. I did not get a working data call out of HAL today, so I am putting it here honestly rather than pretending it is a clean entry. If you can reach it from a residential or institutional IP, treat that 200 as meaningless and check for an &lt;code&gt;error&lt;/code&gt; key first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honorable mention, bioRxiv.&lt;/strong&gt; For life-sciences preprints, bioRxiv's API returned a clean keyless 200:&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="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"141"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"collection"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"MVA Vector Vaccines ..."&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;It is genuinely keyless and useful. I left it out of the main eight for two reasons: it is a single-discipline preprint server, not a cross-publisher index, and it sits in a biomedical area distinct from the general scholarly layer these eight cover. Even here it plants a type trap worth noting: &lt;code&gt;count&lt;/code&gt; is the number &lt;code&gt;30&lt;/code&gt; while &lt;code&gt;total&lt;/code&gt; is the string &lt;code&gt;"141"&lt;/code&gt; in the same object, so cast before you compare. Docs at &lt;a href="https://api.biorxiv.org/" rel="noopener noreferrer"&gt;api.biorxiv.org&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the paid giants are not here
&lt;/h2&gt;

&lt;p&gt;The roundup stays honest by naming what it skips. Scopus (Elsevier), Web of Science (Clarivate), and Dimensions (Digital Science) are the big commercial scholarly databases, and every one of them wants an API key tied to a subscription or an approved account. Good coverage, real money, institutional contracts. Exactly the paid tier this list exists to route around, not entries in it. If your employer already pays for one, use it. If you are building on your own, the eight above are your layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the payload, not the status code
&lt;/h2&gt;

&lt;p&gt;Finding the endpoint is ten percent of the work. Here is the ninety percent, pulled straight from the failures above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the content-type before you parse.&lt;/strong&gt; arXiv hands you Atom XML on a 200; HAL hands you an error object on a 200. A blind &lt;code&gt;resp.json()&lt;/code&gt; either throws or, worse, a lazy &lt;code&gt;try/except&lt;/code&gt; swallows it and you index nothing. Assert &lt;code&gt;application/json&lt;/code&gt; first, and look for an &lt;code&gt;error&lt;/code&gt; key second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on a body count, not the HTTP code.&lt;/strong&gt; Crossref's no-match query, DataCite's &lt;code&gt;data: []&lt;/code&gt;, DOAJ's &lt;code&gt;total: 0&lt;/code&gt;, and OpenCitations' &lt;code&gt;[]&lt;/code&gt; are all 200s. And every one can mean "we do not index this," not "it does not exist." Read the count or total field and treat zero as a coverage gap you might fill from a second source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat the top search hit as a candidate, not your paper.&lt;/strong&gt; &lt;code&gt;query=&lt;/code&gt; and &lt;code&gt;search=&lt;/code&gt; on Crossref, OpenAlex, and DataCite are fuzzy full-text ranking. &lt;code&gt;items[0]&lt;/code&gt; is a guess. For the exact work, resolve by DOI or match precise fields and the returned &lt;code&gt;score&lt;/code&gt;. It is the same one-entity-many-IDs problem OpenCitations shows you outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a real &lt;code&gt;mailto&lt;/code&gt; and User-Agent.&lt;/strong&gt; Crossref, OpenAlex, and Unpaywall all reward an identified client with the polite pool and punish anonymous callers with the first throttle. No key does not mean no etiquette.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assume keyless windows drift.&lt;/strong&gt; CORE answers keyless today and officially wants a key; Semantic Scholar's shared pool is already handing out 429s; Zenodo's live &lt;code&gt;x-ratelimit-limit&lt;/code&gt; header caps you near 30 per minute. "Free and no key" is a snapshot from July 9, 2026, not a warranty.&lt;/p&gt;

&lt;p&gt;Here is a guard that folds the first four checks into one function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local -- validate content-type and body, not just the status code
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;openalex_top&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.openalex.org/works&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&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;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per-page&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-research-tool/1.0 (mailto:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                         &lt;span class="c1"&gt;# honest 4xx/5xx, NOT a 200 with an error body
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&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="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                              &lt;span class="c1"&gt;# arXiv-style XML / HAL-style bodies stop here
&lt;/span&gt;    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                              &lt;span class="c1"&gt;# HAL returns 200 + {"error": ...}
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                              &lt;span class="c1"&gt;# 200 + empty = coverage gap, not a match
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                    &lt;span class="c1"&gt;# a CANDIDATE -- still verify before trusting
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openalex_top&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;large language models&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;# -&amp;gt; a record
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;openalex_top&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;asdfqwer no such work zxcv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# -&amp;gt; None, no crash
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;raise_for_status&lt;/code&gt; catches the honest failures. The content-type line catches arXiv and HAL. The &lt;code&gt;error&lt;/code&gt;-key line catches HAL's 200. The &lt;code&gt;count&lt;/code&gt; line catches the empty coverage gaps. What it deliberately does not do is trust &lt;code&gt;results[0]&lt;/code&gt; as your exact paper. That last check is yours to write, because only you know which work you meant.&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the output is always a table of rows that need trustworthy enrichment columns. Company identity was one &lt;a href="https://blog.spinov.online/blog/free-company-data-apis-no-key/" rel="noopener noreferrer"&gt;keyless column&lt;/a&gt;. Nutrition was &lt;a href="https://blog.spinov.online/blog/free-food-nutrition-apis-no-key/" rel="noopener noreferrer"&gt;another&lt;/a&gt;. Scholarly metadata is this one. Same keyless, no-card layer, different question, and the same failure mode every time: a 200 that is empty, fuzzy, or the wrong entity, almost never an honest 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are research paper APIs really free with no key?&lt;/strong&gt;&lt;br&gt;
Yes. arXiv, Crossref, OpenAlex, DataCite, Unpaywall (with an &lt;code&gt;email&lt;/code&gt; parameter), DOAJ, OpenCitations, and Zenodo all return metadata with no API key and no signup. The big commercial databases (Scopus, Web of Science, Dimensions) require a paid key. Semantic Scholar and CORE technically have keyless pools but throttle hard and officially want a key, so they are labeled honestly rather than listed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does arXiv return XML instead of JSON?&lt;/strong&gt;&lt;br&gt;
arXiv's API predates the JSON-everywhere era and returns Atom XML (&lt;code&gt;content-type: application/atom+xml&lt;/code&gt;) with no JSON mode. Parse it with a feed or XML parser, not &lt;code&gt;json.loads&lt;/code&gt;, and read &lt;code&gt;&amp;lt;opensearch:totalResults&amp;gt;&lt;/code&gt; to tell a hit from a miss, because a no-match query still returns HTTP 200 with an empty &lt;code&gt;&amp;lt;feed&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Crossref and OpenAlex?&lt;/strong&gt;&lt;br&gt;
Crossref is the DOI registrar: authoritative bibliographic metadata for registered works. OpenAlex is a graph built on top of Crossref and other sources, adding authors, institutions, and citation links. For a canonical DOI record, use Crossref; for a connected graph of works and authors, use OpenAlex. Both run fuzzy full-text search, so neither guarantees the top result is your exact paper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a scholarly API return millions of results but not my paper?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;query=&lt;/code&gt; and &lt;code&gt;search=&lt;/code&gt; are relevance-ranked full-text matching, not exact lookup. A &lt;code&gt;total-results&lt;/code&gt; of 3,142,373 (Crossref) or a &lt;code&gt;count&lt;/code&gt; of 3,780,168 (OpenAlex) on July 9, 2026 describes the whole fuzzy match set; &lt;code&gt;items[0]&lt;/code&gt; is the best guess. Resolve the exact work by DOI, or by matching precise fields and the returned score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which free API gives open-access PDF links?&lt;/strong&gt;&lt;br&gt;
Unpaywall: pass a DOI and your &lt;code&gt;email&lt;/code&gt;, and it returns &lt;code&gt;best_oa_location&lt;/code&gt; with a legal open-access URL when one exists. A paywalled paper returns HTTP 200 with &lt;code&gt;best_oa_location: null&lt;/code&gt;, so check &lt;code&gt;is_oa&lt;/code&gt; and guard the null before you read &lt;code&gt;.url&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do these free APIs have rate limits?&lt;/strong&gt;&lt;br&gt;
Yes, keyless is not unlimited. Zenodo caps anonymous clients near 30 requests per minute (its live &lt;code&gt;x-ratelimit-limit: 30&lt;/code&gt; header) before a 429; Semantic Scholar's shared keyless pool was already returning 429 on July 9, 2026; Crossref and OpenAlex run a polite pool that gives identified clients (a &lt;code&gt;mailto&lt;/code&gt;) more stable service than anonymous ones. For steady load, send a real User-Agent with contact info and add backoff.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every API above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 9, 2026 before publishing; responses are trimmed, not paraphrased. I have not run these scholarly APIs in production; the 2,190 runs are a different domain, cited only for the parse-the-body pattern. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless API layer I test. And tell me: which scholarly or research API has quietly handed you a clean 200 with the wrong body (arXiv's XML, a fuzzy top hit, an empty coverage gap), and which field finally gave the bug away? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>opendata</category>
      <category>ai</category>
    </item>
    <item>
      <title>8 Free Food &amp; Nutrition APIs (No Key, Tested 2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Thu, 09 Jul 2026 03:18:01 +0000</pubDate>
      <link>https://dev.to/0012303/8-free-food-nutrition-apis-no-key-tested-2026-3doh</link>
      <guid>https://dev.to/0012303/8-free-food-nutrition-apis-no-key-tested-2026-3doh</guid>
      <description>&lt;p&gt;On July 8, 2026 I looked up a barcode that does not exist. Eight zeros. I sent them to Open Food Facts, the largest open nutrition database on the web, and it answered HTTP 200. Green light. Then I read the body: &lt;code&gt;"status":0&lt;/code&gt;, &lt;code&gt;"status_verbose":"no code or invalid code"&lt;/code&gt;. A success code wrapped around a total miss. Ten seconds of trusting the status line and I would have written that empty result into a calorie tracker as if it were food.&lt;/p&gt;

&lt;p&gt;That is the whole post. The list of APIs is the easy part. The hard part is that a keyless food API hands you a clean 200 and a wrong answer, and it does it a slightly different way on almost every endpoint.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free food API&lt;/strong&gt; here means a public nutrition, ingredient, or recipe endpoint that returns JSON with no API key, no signup, and no card. Not a CSV dump, not a partner form, not a portal from 2012. A real REST call you can paste into a terminal right now. I found eight that clear that bar, plus three worth knowing that quietly lean on a shared key. I re-verified every one with a live &lt;code&gt;curl&lt;/code&gt; on July 8, 2026 (real HTTP code, real body, trimmed but never paraphrased). If you build calorie trackers, meal planners, grocery tools, or an AI agent that answers "how much sugar is in this," these are the lookups you reach for. Every one of them can lie to you with a 200.&lt;/p&gt;

&lt;p&gt;Here is the uncomfortable finding before the list. Keyless nutrition data in 2026 is mostly one project. Open Food Facts and its sibling databases (Pet Food, Products, Beauty, Prices) are six of the eight entries below: five distinct databases on one shared engine, with Open Food Facts itself showing up twice because it fails two different ways. Only two entries, Fruityvice and Wger, are independent, and Wger re-imports its data from Open Food Facts anyway. That concentration is not a weakness of the roundup. It is the point. Because it is one engine, the data-quality traps below are systemic, not one-offs. Learn them once and they repeat across the whole family.&lt;/p&gt;

&lt;p&gt;Let me be straight about scope, because this is where roundups usually stretch the truth. Full disclosure: I have &lt;strong&gt;not&lt;/strong&gt; run these food APIs in production. My numbers come from a different domain (2,190 scraper runs, incl. 962 on a Trustpilot scraper). I'm citing them only for the &lt;em&gt;pattern&lt;/em&gt;: parse the payload, don't trust the status code. The &lt;code&gt;status:0&lt;/code&gt;-with-HTTP-200 and unit-mismatch examples below are all from live &lt;code&gt;curl&lt;/code&gt; calls I ran on 2026-07-08, not from operating these services at scale.&lt;/p&gt;

&lt;p&gt;Here is the full set at a glance.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;No key?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Open Food Facts&lt;/td&gt;
&lt;td&gt;Packaged food by barcode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET world.openfoodfacts.org/api/v2/product/&amp;lt;barcode&amp;gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Open Food Facts (units)&lt;/td&gt;
&lt;td&gt;Per-100g vs per-serving, kJ vs kcal&lt;/td&gt;
&lt;td&gt;same endpoint, &lt;code&gt;nutriments&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Open Pet Food Facts&lt;/td&gt;
&lt;td&gt;Pet food by barcode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET world.openpetfoodfacts.org/api/v2/product/&amp;lt;barcode&amp;gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Open Products Facts&lt;/td&gt;
&lt;td&gt;Non-food products by barcode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET world.openproductsfacts.org/api/v2/product/&amp;lt;barcode&amp;gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Open Beauty Facts&lt;/td&gt;
&lt;td&gt;Cosmetics by barcode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET world.openbeautyfacts.org/api/v2/product/&amp;lt;barcode&amp;gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Open Food Facts Prices&lt;/td&gt;
&lt;td&gt;Crowd-sourced product prices&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET prices.openfoodfacts.org/api/v1/prices?size=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Fruityvice&lt;/td&gt;
&lt;td&gt;Nutrition for whole fruits&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET fruityvice.com/api/fruit/banana&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Wger&lt;/td&gt;
&lt;td&gt;1.3M+ ingredients + workouts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET wger.de/api/v2/ingredient/?limit=1&amp;amp;language=2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three bonus APIs (TheMealDB, TheCocktailDB, USDA FoodData Central) sit at the end with an honest label, because they ride a shared public key rather than being truly keyless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open Food Facts and its five siblings: one engine, six entries
&lt;/h2&gt;

&lt;p&gt;This is the keyless nutrition web, most of it. Same codebase, same response shape, same traps in slightly different clothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Open Food Facts: the barcode lookup that returns 200 on a missing product
&lt;/h3&gt;

&lt;p&gt;Open Food Facts is a crowd-sourced database of packaged food. Give the v2 product endpoint a barcode and it returns the product with a nested &lt;code&gt;nutriments&lt;/code&gt; object. This is the anchor of the whole post, so look closely at what it does when the barcode is real and when it is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://world.openfoodfacts.org/api/v2/product/3017624010701.json"&lt;/span&gt;   &lt;span class="c"&gt;# Nutella&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://world.openfoodfacts.org/api/v2/product/00000000.json"&lt;/span&gt;        &lt;span class="c"&gt;# garbage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;real&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;barcode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3017624010701&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"3017624010701"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"nutriments"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="nl"&gt;"status"&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="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;invalid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;barcode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;00000000&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"00000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status_verbose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"no code or invalid code"&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;Both requests returned HTTP 200. The only thing that separates a hit from a miss is &lt;code&gt;status&lt;/code&gt; in the body: &lt;code&gt;1&lt;/code&gt; means found, &lt;code&gt;0&lt;/code&gt; means nothing. There is no 404 to catch. It gets worse if you ask for specific fields. When I requested &lt;code&gt;?fields=nutrition_data_per&lt;/code&gt; on the Nutella barcode, I got back &lt;code&gt;{"product":{},"status":1,"status_verbose":"product found"}&lt;/code&gt;: status says found, and &lt;code&gt;product&lt;/code&gt; is an empty object. A 200, a "found," and no data. If your code does &lt;code&gt;resp.json()["product"]["nutriments"]&lt;/code&gt; on that, it throws a KeyError on a request that technically succeeded. Trust the &lt;code&gt;status&lt;/code&gt; field, not the status code. Docs at &lt;a href="https://openfoodfacts.github.io/openfoodfacts-server/api/" rel="noopener noreferrer"&gt;openfoodfacts.github.io/openfoodfacts-server/api&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; any barcode-to-nutrition lookup for packaged food. It is the front door of the whole ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Open Food Facts again: the units trap (kJ vs kcal, per-100g vs per-serving)
&lt;/h3&gt;

&lt;p&gt;Yes, this is the same endpoint. It earns a second slot because the &lt;code&gt;nutriments&lt;/code&gt; object fails a second, sneakier way, and this one does not throw at all. It just gives you a plausible wrong number. I pulled Coca-Cola, barcode &lt;code&gt;5449000000996&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://world.openfoodfacts.org/api/v2/product/5449000000996.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"nutriments"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"energy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"energy-kcal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"energy-kj"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"energy-kcal_serving"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;139&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"energy-kj_serving"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;594&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"carbohydrates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;10.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"carbohydrates_serving"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;35&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;Read &lt;code&gt;energy&lt;/code&gt; and you get &lt;code&gt;180&lt;/code&gt;. Looks like calories. It is not. That &lt;code&gt;180&lt;/code&gt; is kilojoules; the calorie value is &lt;code&gt;energy-kcal&lt;/code&gt;, which is &lt;code&gt;42&lt;/code&gt;. Grab the naive &lt;code&gt;energy&lt;/code&gt; field as "calories per 100g" and your tracker is wrong by a factor of about 4.3 on every Coke in the database. The same field lives four ways at once: &lt;code&gt;_100g&lt;/code&gt;, &lt;code&gt;_serving&lt;/code&gt;, kJ, and kcal. And notice &lt;code&gt;carbohydrates&lt;/code&gt; is &lt;code&gt;10.6&lt;/code&gt; (per 100g) while &lt;code&gt;carbohydrates_serving&lt;/code&gt; is &lt;code&gt;35&lt;/code&gt; (a full can). Mix the basis and you are comparing a mouthful to a bottle. Pick your unit and your basis explicitly, then normalize before you store anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; whenever you actually read nutrient values, which is always. This is where calorie apps quietly ship the wrong number.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Open Pet Food Facts: the same engine, a different status code
&lt;/h3&gt;

&lt;p&gt;Open Pet Food Facts is the sibling database for pet food, same v2 product API. I looked up a barcode it does not have.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"https://world.openpetfoodfacts.org/api/v2/product/3182550716291.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"3182550716291"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status_verbose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"product not found"&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;Here is the twist that makes hardcoding dangerous. The main Open Food Facts endpoint returned &lt;strong&gt;200&lt;/strong&gt; on a not-found product (entry 1). This one, same engine, returned &lt;strong&gt;404&lt;/strong&gt; on a not-found product. Two databases, one codebase, two different transport signals for the exact same situation. What is identical across both is the body: &lt;code&gt;status:0&lt;/code&gt;. So if you wrote &lt;code&gt;if resp.status_code == 200: assume_found()&lt;/code&gt;, it works on pet food and silently breaks on food. The body &lt;code&gt;status&lt;/code&gt; field is the one signal that holds across the whole family. Docs at &lt;a href="https://world.openpetfoodfacts.org/" rel="noopener noreferrer"&gt;openpetfoodfacts.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; pet-food barcode lookups, and as your reminder that status codes are not consistent even within one project.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Open Products Facts: 404 again, so stop hardcoding the status
&lt;/h3&gt;

&lt;p&gt;Open Products Facts covers non-food consumer products. Same API, and it confirms the lesson from entry 3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"https://world.openproductsfacts.org/api/v2/product/3661112538014.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"3661112538014"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status_verbose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"product not found"&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;Another 404, not a 200, on not-found. So now you have concrete proof from three sibling databases that you cannot assume "this API always returns 200" or "this API always returns 404." The transport code depends on which sibling you hit. Read &lt;code&gt;status&lt;/code&gt; from the body every time. Docs at &lt;a href="https://world.openproductsfacts.org/" rel="noopener noreferrer"&gt;openproductsfacts.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; barcode lookups for household and non-food goods.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Open Beauty Facts: status 1, and almost nothing in the record
&lt;/h3&gt;

&lt;p&gt;Open Beauty Facts is the cosmetics sibling. It is not food, and I am flagging that plainly, but it belongs here because it shows the family's other failure mode: a "found" record that is nearly empty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://world.openbeautyfacts.org/api/v2/product/3600542525701.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"3600542525701"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"product_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Shampooing"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status_verbose"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"product found"&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;status:1&lt;/code&gt;, "product found," and the product is one field: a name, "Shampooing." No ingredients, no detail. This is the sparse-record trap. On crowd-sourced data, "found" means someone scanned a barcode once, not that anyone filled in the fields you need. A completeness check (does the field I want actually exist and is it non-empty) is a separate test from the &lt;code&gt;status&lt;/code&gt; check. Docs at &lt;a href="https://world.openbeautyfacts.org/" rel="noopener noreferrer"&gt;openbeautyfacts.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; cosmetics lookups, and as a warning that &lt;code&gt;status:1&lt;/code&gt; is a floor, not a guarantee of complete data.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Open Food Facts Prices: a 200 full of nulls
&lt;/h3&gt;

&lt;p&gt;Prices is a newer Open Food Facts service where people log what products cost. Different host, its own v1 API, still keyless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://prices.openfoodfacts.org/api/v1/prices?size=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&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="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"brands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nutriscore_grade"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"categories_tags"&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;HTTP 200, valid JSON, an &lt;code&gt;items&lt;/code&gt; array with a real row. And the joined &lt;code&gt;product&lt;/code&gt; object is almost entirely &lt;code&gt;null&lt;/code&gt;: no name, no brand, no grade, an empty categories array. The price record exists; the product it points to was never enriched. This is the classic "200, but the fields are empty" case. A null-coalescing default on every joined field is not optional here, it is the difference between a working feature and a page full of "null." Docs at &lt;a href="https://prices.openfoodfacts.org/" rel="noopener noreferrer"&gt;prices.openfoodfacts.org&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; crowd-sourced grocery-price features, with heavy null guards on the joined product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two keyless APIs that aren't Open Food Facts
&lt;/h2&gt;

&lt;p&gt;Out of eight keyless entries, exactly two come from a different codebase. One of them handles errors better than the whole Open Facts family. The other inherits Open Food Facts' data and adds a fresh bug on top.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Fruityvice: the one that gets not-found right
&lt;/h3&gt;

&lt;p&gt;Fruityvice returns nutrition for whole fruits, keyed by name. It is small and single-purpose, and it is the counter-example that proves good behavior is possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.fruityvice.com/api/fruit/banana"&lt;/span&gt;   &lt;span class="c"&gt;# 200&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"https://www.fruityvice.com/api/fruit/pizza"&lt;/span&gt; &lt;span class="c"&gt;# 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;banana,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Banana"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"nutritions"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"calories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"fat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sugar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;17.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"carbohydrates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;22.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"protein"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pizza,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Not found"&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;A miss returns an honest &lt;strong&gt;404&lt;/strong&gt; with &lt;code&gt;{"error":"Not found"}&lt;/code&gt;, not a 200 with a hidden flag. Refreshing after five endpoints that lie about status. But it hides a different assumption: the serving basis is never stated. Those numbers are per 100 grams (a banana is not 96 calories, a 100g portion of banana is), and nothing in the payload says so. Bake in the wrong basis and every fruit in your tracker is off. A silent assumption is still a bug; it just fails quietly instead of throwing. Docs at &lt;a href="https://www.fruityvice.com/" rel="noopener noreferrer"&gt;fruityvice.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; fast fruit-nutrition lookups, once you have confirmed and hardcoded the per-100g basis.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Wger: 1.3M ingredients, every nutrient a string
&lt;/h3&gt;

&lt;p&gt;Wger is an open-source workout and nutrition manager with a public REST API. The ingredient endpoint is large and keyless. Two calls below: one for the catalog size, one for a single ingredient pinned by id so you get the exact same row I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://wger.de/api/v2/ingredient/?limit=1&amp;amp;language=2"&lt;/span&gt;  &lt;span class="c"&gt;# English catalog size&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://wger.de/api/v2/ingredient/33208/"&lt;/span&gt;               &lt;span class="c"&gt;# one ingredient, pinned by id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;endpoint,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(results&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;come&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;back&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;unordered,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;so&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;I&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pin&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;below):&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1358809&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ingredient&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;33208&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fully&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reproducible:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Flammekueche"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"energy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;219&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"protein"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"6.100"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"carbohydrates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"23.100"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"fiber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"is_vegan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"source_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Open Food Facts"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;count&lt;/code&gt; is 1,358,809 English ingredients. Drop &lt;code&gt;language=2&lt;/code&gt; and it jumps to roughly 3 million rows across all languages, so the "how big is it" number depends entirely on the filter you send. Either way it sounds like a rival to Open Food Facts, until you read &lt;code&gt;source_name&lt;/code&gt;: "Open Food Facts." Wger re-imports the same data. So it inherits Open Food Facts' gaps (&lt;code&gt;fiber&lt;/code&gt; and &lt;code&gt;is_vegan&lt;/code&gt; come back &lt;code&gt;null&lt;/code&gt;) and adds one of its own: the nutrients are &lt;strong&gt;strings&lt;/strong&gt;. &lt;code&gt;"6.100"&lt;/code&gt;, not &lt;code&gt;6.1&lt;/code&gt;. Try to add two of those in Python or JavaScript and &lt;code&gt;+&lt;/code&gt; concatenates them into &lt;code&gt;"6.10023.100"&lt;/code&gt; instead of summing. Cast every nutrient to a number before you do arithmetic. One more trap the list endpoint hides: it returns rows in no guaranteed order, so a bare &lt;code&gt;?limit=1&lt;/code&gt; hands you a different ingredient on every call, which is why I pinned id 33208 instead of trusting "the first row." This is the downstream lesson: when one dataset re-imports another, the original's defects flow through and the new layer can add fresh ones. Wger also serves a separate workout catalog if you need exercises. Docs at &lt;a href="https://wger.de/en/software/api" rel="noopener noreferrer"&gt;wger.de&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; ingredient search at volume, or workout data, with a hard cast on every numeric field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three bonus food and recipe APIs that need a shared key
&lt;/h2&gt;

&lt;p&gt;These three are genuinely useful and genuinely popular, so people search for them. But they are not clean keyless: TheMealDB and TheCocktailDB run on a public shared test key &lt;code&gt;1&lt;/code&gt;, and USDA uses &lt;code&gt;DEMO_KEY&lt;/code&gt;. Those are shared, rate-limited, and not yours. They can throttle or break without warning. Included honestly, with the label attached.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. TheMealDB: recipes, and the null-that-isn't-an-array trap
&lt;/h3&gt;

&lt;p&gt;TheMealDB is a free recipe database. The public test key &lt;code&gt;1&lt;/code&gt; is in the path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only (shared public test key "1")&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.themealdb.com/api/json/v1/1/search.php?s=arrabiata"&lt;/span&gt; &lt;span class="c"&gt;# hit&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.themealdb.com/api/json/v1/1/search.php?s=zzzzzz"&lt;/span&gt;    &lt;span class="c"&gt;# miss&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;hit,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"meals"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"idMeal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"52771"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"strMeal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Spicy Arrabiata Penne"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;miss,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"meals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are HTTP 200. A miss does not return &lt;code&gt;{"meals":[]}&lt;/code&gt;. It returns &lt;code&gt;{"meals":null}&lt;/code&gt;. That difference is a crash. &lt;code&gt;for m in data["meals"]&lt;/code&gt; throws &lt;code&gt;TypeError: 'NoneType' is not iterable&lt;/code&gt; in Python, and &lt;code&gt;data.meals.length&lt;/code&gt; throws in JavaScript, on a response that succeeded. Guard for &lt;code&gt;null&lt;/code&gt;, not just for empty. Docs at &lt;a href="https://www.themealdb.com/api.php" rel="noopener noreferrer"&gt;themealdb.com/api.php&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; recipe search and meal ideas, with a &lt;code&gt;null&lt;/code&gt; check before you iterate, and a plan for when the shared key gets rate-limited.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. TheCocktailDB: same engine, same null trap
&lt;/h3&gt;

&lt;p&gt;TheCocktailDB is TheMealDB's drinks counterpart, same company, same behavior, same shared key &lt;code&gt;1&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only (shared public test key "1")&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.thecocktaildb.com/api/json/v1/1/search.php?s=margarita"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"drinks"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"idDrink"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"11007"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"strDrink"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Margarita"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"strInstructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Rub the rim of the glass with the lime slice ..."&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;A hit returns a &lt;code&gt;drinks&lt;/code&gt; array; a miss returns &lt;code&gt;{"drinks":null}&lt;/code&gt;, exactly like TheMealDB. If you already wrote a null guard for meals, reuse it verbatim here. Docs at &lt;a href="https://www.thecocktaildb.com/api.php" rel="noopener noreferrer"&gt;thecocktaildb.com/api.php&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; cocktail and drink recipes, same null caveat as TheMealDB.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. USDA FoodData Central: a DEMO_KEY and contradictory metadata
&lt;/h3&gt;

&lt;p&gt;USDA FoodData Central is the US government's authoritative food-composition database. It needs a key, but &lt;code&gt;DEMO_KEY&lt;/code&gt; works for a look (shared and rate-limited, the same demo key NASA uses).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only (shared DEMO_KEY, rate-limited)&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.nal.usda.gov/fdc/v1/foods/search?query=cheddar&amp;amp;pageSize=1&amp;amp;api_key=DEMO_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"totalHits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20607&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"totalPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20607&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"foodSearchCriteria"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"numberOfResultsPerPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pageSize"&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="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"foods"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"fdcId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2022017&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"CHEDDAR"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data is excellent. The metadata argues with itself. I asked for &lt;code&gt;pageSize=1&lt;/code&gt; and the response echoes &lt;code&gt;"pageSize":1&lt;/code&gt;, then reports &lt;code&gt;"numberOfResultsPerPage":50&lt;/code&gt; and &lt;code&gt;"totalPages":20607&lt;/code&gt; in the same object, for 20,607 hits. Those numbers cannot all be true at once. Do not build pagination logic off the meta block blind; trust the length of the &lt;code&gt;foods&lt;/code&gt; array you actually received. Get your own free key from &lt;a href="https://fdc.nal.usda.gov/api-guide.html" rel="noopener noreferrer"&gt;fdc.nal.usda.gov/api-guide&lt;/a&gt; before you ship anything real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; authoritative US nutrient data, with your own key and a skeptical eye on the pagination metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the famous food APIs aren't on this list
&lt;/h2&gt;

&lt;p&gt;The roundup stays honest by naming what it left out.&lt;/p&gt;

&lt;p&gt;The big commercial names all want a key or an OAuth handshake: Nutritionix, Edamam, Spoonacular, CalorieNinjas, FatSecret. Good products, but none of them clear the "no key, no signup" bar, so they are the paid tier this list exists to route around, not entries in it.&lt;/p&gt;

&lt;p&gt;Two Open Food Facts search paths flaked during verification, and I am not going to pretend otherwise. The legacy search at &lt;code&gt;/cgi/search.pl&lt;/code&gt; returned &lt;strong&gt;HTTP 503&lt;/strong&gt; ("Page temporarily unavailable") twice on July 8, so do not build a product on it. The newer Search-a-licious path I tried returned &lt;strong&gt;HTTP 404&lt;/strong&gt; with &lt;code&gt;{"detail":"Not Found"}&lt;/code&gt;, which means the URL has moved; use the current docs rather than any fixed search URL you find in a blog post, including this one.&lt;/p&gt;

&lt;p&gt;Honorable keyless mention that is not nutrition: Foodish (&lt;code&gt;foodish-api.com/api/&lt;/code&gt;) returns a random food photo, &lt;code&gt;{"image":"https://.../idly3.jpg"}&lt;/code&gt;, HTTP 200, no key. Fun for a demo. It is pictures, not data, so it does not count toward the eight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the payload, not the status code
&lt;/h2&gt;

&lt;p&gt;Finding the endpoint is ten percent of the work. Here is the ninety percent, pulled straight from the failures above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on a field in the body, not on the HTTP code.&lt;/strong&gt; Every trap on this page slips past a status check. Open Food Facts returns 200 with &lt;code&gt;status:0&lt;/code&gt; on a bad barcode. Prices returns 200 with a null product. TheMealDB returns 200 with &lt;code&gt;meals:null&lt;/code&gt;. A &lt;code&gt;if resp.status_code == 200&lt;/code&gt; gate waves all three through. Check the thing that actually signals success: &lt;code&gt;status == 1&lt;/code&gt;, a non-empty result, a field you expect to exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never hardcode "this API always returns 200."&lt;/strong&gt; The same Open Facts engine returned 200 on a missing food and 404 on a missing pet food. Transport codes are not consistent even inside one project, so read the body every time instead of branching on the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalize units and types before you store.&lt;/strong&gt; Coca-Cola's &lt;code&gt;energy&lt;/code&gt; is 180 kJ while &lt;code&gt;energy-kcal&lt;/code&gt; is 42. Wger hands you &lt;code&gt;"6.100"&lt;/code&gt; as a string. Pick a unit and a basis (per-100g or per-serving), cast every number, and do it at the boundary, before the value reaches your database, not after a bug report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a real User-Agent.&lt;/strong&gt; Open Food Facts asks clients to identify themselves, and the whole family rewards a descriptive agent and polite pacing over a hammering anonymous one. No key does not mean no etiquette.&lt;/p&gt;

&lt;p&gt;Here is a five-line guard that folds most of this into one function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# runnable local — validate the body, not the status code
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_nutriment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;barcode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;energy-kcal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://world.openfoodfacts.org/api/v2/product/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;barcode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&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;my-calorie-app/1.0 (you@example.com)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                     &lt;span class="c1"&gt;# catches 404/500, NOT the 200+status:0
&lt;/span&gt;    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="c1"&gt;# the real success flag lives here
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                          &lt;span class="c1"&gt;# invalid/missing barcode: 200 + status:0
&lt;/span&gt;    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nutriments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;  &lt;span class="c1"&gt;# rejects Wger-style "6.100" and null
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_nutriment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3017624010701&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# Nutella kcal/100g -&amp;gt; a number
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_nutriment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;00000000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;# invalid barcode  -&amp;gt; None, no crash
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;raise_for_status&lt;/code&gt; line catches the honest failures. The &lt;code&gt;status != 1&lt;/code&gt; line catches the dishonest 200s. The &lt;code&gt;isinstance&lt;/code&gt; line catches string and null nutrients. Three checks, and the empty results stop reaching your data.&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the output is always a table of rows that need trustworthy enrichment columns. Company identity was the &lt;a href="https://blog.spinov.online/blog/free-company-data-apis-no-key/" rel="noopener noreferrer"&gt;registry column&lt;/a&gt;. Time and dates were the &lt;a href="https://blog.spinov.online/blog/free-public-holiday-time-apis-no-key/" rel="noopener noreferrer"&gt;holiday column&lt;/a&gt;. Nutrition is this one. Same keyless, no-card layer, different question, and the same failure mode every single time: a 200 that is empty, stale, or misread, never an honest 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are food and nutrition APIs really free with no key?&lt;/strong&gt;&lt;br&gt;
Yes. Open Food Facts and its siblings (Pet Food, Products, Beauty, Prices), plus Fruityvice and Wger, return JSON with no key and no signup. The popular commercial APIs (Nutritionix, Edamam, Spoonacular) do require a key, and TheMealDB, TheCocktailDB, and USDA ride a shared public or demo key, so they are labeled as bonuses here rather than clean keyless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which free food API has the most products?&lt;/strong&gt;&lt;br&gt;
Open Food Facts is the largest keyless option and the source most others copy. Wger reports 1,358,809 ingredients in its English catalog (about 3 million across all languages), but &lt;code&gt;source_name&lt;/code&gt; shows it re-imports from Open Food Facts, so it is the same data with an extra serialization quirk (nutrients arrive as strings like &lt;code&gt;"6.100"&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Open Food Facts return HTTP 200 for a barcode that does not exist?&lt;/strong&gt;&lt;br&gt;
Because the request was handled successfully even though nothing was found. The API signals the miss in the body with &lt;code&gt;"status":0&lt;/code&gt; and &lt;code&gt;"status_verbose":"no code or invalid code"&lt;/code&gt;, not with a 404. Check the &lt;code&gt;status&lt;/code&gt; field, not the HTTP status code. Confusingly, the sibling Pet Food and Products databases do return 404 on a miss, which is exactly why you read the body every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between &lt;code&gt;energy&lt;/code&gt; and &lt;code&gt;energy-kcal&lt;/code&gt; in Open Food Facts?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;energy&lt;/code&gt; is in kilojoules and &lt;code&gt;energy-kcal&lt;/code&gt; is in kilocalories (the "Calories" on a US label). For Coca-Cola I measured &lt;code&gt;energy&lt;/code&gt; at 180 (kJ) and &lt;code&gt;energy-kcal&lt;/code&gt; at 42 (kcal). Reading &lt;code&gt;energy&lt;/code&gt; as calories overstates the value by roughly 4.3x, so always read &lt;code&gt;energy-kcal&lt;/code&gt; for calories and normalize the per-100g versus per-serving basis before storing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which free recipe API needs no key?&lt;/strong&gt;&lt;br&gt;
TheMealDB and TheCocktailDB work through a public shared test key &lt;code&gt;1&lt;/code&gt; in the URL path, so they are effectively keyless but not your key: shared, rate-limited, and able to break without notice. Both return &lt;code&gt;{"meals":null}&lt;/code&gt; or &lt;code&gt;{"drinks":null}&lt;/code&gt; on a miss rather than an empty array, so guard for &lt;code&gt;null&lt;/code&gt; before you iterate or you will crash a request that returned HTTP 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Open Food Facts data commercially?&lt;/strong&gt;&lt;br&gt;
The database is under the Open Database License (ODbL), which permits commercial use but carries attribution and share-alike obligations on the database itself. Fruityvice, Wger, and USDA have their own terms. Check the specific license before you ship, because "keyless" and "unrestricted" are not the same thing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every API above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (real HTTP code, real body) on July 8, 2026 before publishing; responses are trimmed, not paraphrased. I did not run these food APIs in production; the 2,190 runs are a different domain, cited only for the pattern. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next keyless API I test for the enrichment layer. And tell me: which food or nutrition API has quietly handed you a clean 200 with a wrong body, and which field finally gave the bug away? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>opendata</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>10 Free Government APIs With No Key or Signup (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Wed, 08 Jul 2026 01:20:36 +0000</pubDate>
      <link>https://dev.to/0012303/10-free-government-apis-with-no-key-or-signup-2026-4np7</link>
      <guid>https://dev.to/0012303/10-free-government-apis-with-no-key-or-signup-2026-4np7</guid>
      <description>&lt;p&gt;On July 6, 2026 I asked REST Countries for the United States. It answered HTTP 200. Green light, ship it. Then I read the body: &lt;code&gt;"success":false&lt;/code&gt;, &lt;code&gt;"data":null&lt;/code&gt;, and a note that the whole API version is deprecated. A 200 that means no. Ten seconds earlier I would have written that straight into a pipeline as country reference data.&lt;/p&gt;

&lt;p&gt;Same afternoon, the US Census API handed me an HTTP 302 redirect to a "missing key" page for a shape of query that used to be keyless. Neither of those is an error your &lt;code&gt;if resp.status_code == 200&lt;/code&gt; check will catch.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free government API&lt;/strong&gt; is a public-sector or intergovernmental endpoint that returns JSON without a key, a signup, or a card. Not a CSV export, not a FOIA form, not a portal from 2009. A real REST endpoint. The ten below need none of those, and I re-verified each with a live &lt;code&gt;curl&lt;/code&gt; on July 6, 2026 (HTTP 200, real body). If you build data pipelines, dashboards, or AI agents that need authoritative ground truth (federal spending, drug safety, the national debt, clinical trials, biodiversity) these are the lookups I reach for. The kind of facts a language model will happily hallucinate if you let it.&lt;/p&gt;

&lt;p&gt;Here is the counterintuitive part. People expect government data to be the flaky link. In this batch it was the opposite: the ten government and intergovernmental endpoints returned clean, correct JSON, and the two that failed loudest were the community-run alternative (REST Countries, above) and a Census query that now wants a key. These APIs are primary sources. They do not sunset an endpoint to push a new pricing tier. But keyless does not mean careless, and 200 does not mean success.&lt;/p&gt;

&lt;p&gt;Let me be straight about scope, because this is where writers usually lie. I did not run a government-data resolver across all 2,190 of my production runs. That would be false. I called each endpoint once on July 6, 2026, with a single request. The only thing I am carrying over from those 2,190 runs is one narrow, hard-won pattern: enrichment data fails as a confident 200 with a wrong body far more often than as an honest 500, which is exactly why the field-level notes below matter more than the endpoint list. This is a different axis from my recent keyless roundups (company data, holidays, CVEs); zero overlap in providers.&lt;/p&gt;

&lt;p&gt;Here is the full set at a glance, grouped by the job each one does.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;No key?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;USAspending&lt;/td&gt;
&lt;td&gt;Federal agencies and their budgets&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.usaspending.gov/api/v2/references/toptier_agencies/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Treasury FiscalData&lt;/td&gt;
&lt;td&gt;US national debt to the penny&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.fiscaldata.treasury.gov/.../debt_to_penny&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Federal Register&lt;/td&gt;
&lt;td&gt;Daily US government rules and notices&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET federalregister.gov/api/v1/documents.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;openFDA&lt;/td&gt;
&lt;td&gt;Drug, device, and food adverse events&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.fda.gov/drug/event.json?limit=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;PubMed E-utilities&lt;/td&gt;
&lt;td&gt;Search 37M+ biomedical papers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET eutils.ncbi.nlm.nih.gov/.../esearch.fcgi?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;ClinicalTrials.gov v2&lt;/td&gt;
&lt;td&gt;480k+ clinical study records&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET clinicaltrials.gov/api/v2/studies?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;CDC (Socrata)&lt;/td&gt;
&lt;td&gt;Public-health datasets via SoQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET data.cdc.gov/resource/pwn4-m3yp.json?$limit=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;GBIF&lt;/td&gt;
&lt;td&gt;Global species and biodiversity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.gbif.org/v1/species/match?name=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;USGS Earthquakes&lt;/td&gt;
&lt;td&gt;Real-time seismic catalog (GeoJSON)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET earthquake.usgs.gov/fdsnws/event/1/query?...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;World Bank&lt;/td&gt;
&lt;td&gt;16k+ global development indicators&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.worldbank.org/v2/country/us?format=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What makes this a list and not one API: there is no single "ask the government" endpoint. Money lives at Treasury and USAspending, drug safety at the FDA, seismic risk at USGS, global indicators at the World Bank. Which door you knock on depends entirely on the question. Reading the response correctly is the rest of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group A: money, debt, and the federal record
&lt;/h2&gt;

&lt;p&gt;Three keyless endpoints that answer "what is the government doing with money and rules," straight from the source.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. USAspending: every federal agency and what it can spend
&lt;/h3&gt;

&lt;p&gt;Hand USAspending nothing at all and it hands back every top-tier federal agency with its budget authority for the current fiscal year. It is a huge API (award search, recipients, geography), but the agencies reference endpoint is the friendliest first pull.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.usaspending.gov/api/v2/references/toptier_agencies/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"agency_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1525&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"toptier_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"247"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"abbreviation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AAHC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"agency_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"400 Years of African-American History Commission"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"active_fy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"active_fq"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"budget_authority_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"current_total_budget_authority_amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;15823226897068.67&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;Two fields matter and they are not the same. &lt;code&gt;budget_authority_amount&lt;/code&gt; is that one tiny commission's slice (0.0 here). &lt;code&gt;current_total_budget_authority_amount&lt;/code&gt; is the government-wide total, repeated on every row: 15,823,226,897,068.67 dollars, roughly $15.8 trillion of budget authority. Grab the wrong one and your dashboard is off by twelve orders of magnitude. Note &lt;code&gt;active_fy&lt;/code&gt; is 2026 and &lt;code&gt;active_fq&lt;/code&gt; is 3, so you are reading the current fiscal quarter, not a stale snapshot. No published hard rate limit; it answered on a bare &lt;code&gt;curl&lt;/code&gt; user agent, but be polite and cache. Terms at api.usaspending.gov.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; any US federal spending, award, or recipient question. This is the front door.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Treasury FiscalData: the national debt, to the penny
&lt;/h3&gt;

&lt;p&gt;FiscalData is the US Treasury's open data service. The &lt;code&gt;debt_to_penny&lt;/code&gt; endpoint returns the total public debt outstanding for a given day, exact to the cent, with rich labels attached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v2/accounting/od/debt_to_penny?sort=-record_date&amp;amp;page[size]=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"record_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"tot_pub_debt_out_amt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"39375254020492.22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"debt_held_public_amt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"31678903447798.93"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"intragov_hold_amt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"7696350572693.29"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"record_fiscal_year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"record_fiscal_quarter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"4"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"count"&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of July 2, 2026, total public debt outstanding was $39,375,254,020,492.22. The numbers arrive as strings, so cast before you do math. And there is a curl trap worth flagging: the &lt;code&gt;page[size]&lt;/code&gt; parameter has literal square brackets, so plain &lt;code&gt;curl&lt;/code&gt; treats them as a glob range and errors with "bad range." Pass &lt;code&gt;-g&lt;/code&gt; (or &lt;code&gt;--globoff&lt;/code&gt;), or URL-encode the brackets. I have watched people blame the API for that one. Keyless, generous, and it exposes daily operations, exchange rates, and interest rates through sibling endpoints. Docs at fiscaldata.treasury.gov.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; debt, Treasury operations, official exchange rates, anything fiscal with an audit trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Federal Register: the government's daily journal
&lt;/h3&gt;

&lt;p&gt;The Federal Register is where US rules, proposed rules, presidential documents, and public notices are published every business day. The API is keyless and filterable by agency, date, and type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.federalregister.gov/api/v1/documents.json?per_page=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total_pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"next_page_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://www.federalregister.gov/api/v1/documents?...&amp;amp;page=2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Improvements to Rules on Recoupment of Benefit Overpayments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Proposed Rule"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"abstract"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"The Pension Benefit Guaranty Corporation (PBGC) is proposing..."&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;One honest caveat: this is a live daily feed, so &lt;code&gt;results[0]&lt;/code&gt; moves. On July 6 the top item was a PBGC proposed rule on benefit-overpayment recoupment; run the curl and you will get whatever is newest. The shape is the point, not my one document. Note the pagination is cursor-based via &lt;code&gt;next_page_url&lt;/code&gt;, not a simple offset, so follow the link rather than incrementing a page number past the wall. Use &lt;code&gt;fields[]&lt;/code&gt; to slim the payload. Keyless. Docs at federalregister.gov/developers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; regulatory monitoring, compliance calendars, tracking an agency's rulemaking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group B: health, drugs, and clinical data
&lt;/h2&gt;

&lt;p&gt;Four keyless endpoints covering drug safety, the biomedical literature, active trials, and public-health datasets. This is the densest cluster of authoritative data on the list.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. openFDA: adverse events, recalls, and drug labels
&lt;/h3&gt;

&lt;p&gt;openFDA exposes FDA data on drugs, devices, and food: adverse events, recalls, and labels, with Elasticsearch-style &lt;code&gt;search=&lt;/code&gt; and &lt;code&gt;count=&lt;/code&gt; queries. It works with no key, and a free key only raises your limit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.fda.gov/drug/event.json?limit=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"disclaimer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Do not rely on openFDA to make decisions regarding medical care..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://open.fda.gov/terms/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;20328575&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"safetyreportid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"5801206-7"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;meta.results.total&lt;/code&gt; is 20,328,575 adverse-event reports in the drug endpoint alone. The &lt;code&gt;meta&lt;/code&gt; block also ships the disclaimer, terms, and license on every response, which tells you exactly how the data may be used. Read the free-tier limit honestly, because this one is real: without a key you get 240 requests per minute and 1,000 per day per IP address. A free key lifts the daily ceiling to 120,000. If you are batching, you will hit the 1,000/day wall fast, so plan for the key before you ship. Terms at open.fda.gov.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; drug and device safety signals, recall monitoring, label lookups.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. PubMed E-utilities: search 37 million biomedical papers
&lt;/h3&gt;

&lt;p&gt;NCBI's E-utilities are the programmatic door to PubMed. &lt;code&gt;esearch&lt;/code&gt; returns matching article IDs; &lt;code&gt;efetch&lt;/code&gt; and &lt;code&gt;esummary&lt;/code&gt; pull the records. It is the backbone for any retrieval-augmented system over published science.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&amp;amp;term=crispr&amp;amp;retmax=2&amp;amp;retmode=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"esearchresult"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"68891"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"retmax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"retstart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idlist"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"42402242"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"42402225"&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;A search for &lt;code&gt;crispr&lt;/code&gt; returned 68,891 matching papers, with the two newest IDs at the top. Feed those IDs to &lt;code&gt;efetch&lt;/code&gt; for abstracts. The keyless limit is the one to respect: 3 requests per second per IP, and NCBI does enforce it. A free API key raises that to 10 per second. Pass &lt;code&gt;retmode=json&lt;/code&gt; explicitly or you get XML, which is the same "read the response format" trap that bites people on the seismic endpoint below. Docs at ncbi.nlm.nih.gov/books/NBK25501.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; literature search, science RAG, citation tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. ClinicalTrials.gov v2: the registry of clinical studies
&lt;/h3&gt;

&lt;p&gt;ClinicalTrials.gov holds more than 480,000 study records: protocols, statuses, sponsors, and results. The v2 API is a clean modern REST interface, a real upgrade over the legacy one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://clinicaltrials.gov/api/v2/studies?query.term=diabetes&amp;amp;pageSize=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"studies"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"protocolSection"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identificationModule"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"nctId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NCT03470961"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"organization"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"fullName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Tianjin First Central Hospital"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"briefTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Observational Study to Evaluate the Safety and Efficacy of Polyclonal Antibodies in Simultaneous Pancreas Kidney Transplant Recipients"&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;Search &lt;code&gt;diabetes&lt;/code&gt; and the first record is &lt;code&gt;NCT03470961&lt;/code&gt; from Tianjin First Central Hospital. The data is deeply nested under &lt;code&gt;protocolSection&lt;/code&gt;, so the field you want is usually three or four keys down; keep the module names (&lt;code&gt;identificationModule&lt;/code&gt;, &lt;code&gt;statusModule&lt;/code&gt;, &lt;code&gt;sponsorCollaboratorsModule&lt;/code&gt;) handy. Pagination is by &lt;code&gt;pageToken&lt;/code&gt;, not a numeric page. Keyless. Docs at clinicaltrials.gov/data-api/api.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; trial monitoring, sponsor and condition research, medical dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. CDC via Socrata: public-health datasets you can query with SoQL
&lt;/h3&gt;

&lt;p&gt;data.cdc.gov runs on Socrata, which means thousands of public-health datasets answer SoQL queries (&lt;code&gt;$where&lt;/code&gt;, &lt;code&gt;$select&lt;/code&gt;, &lt;code&gt;$limit&lt;/code&gt;) at a JSON endpoint. Anonymous access needs no key; an app token only lifts the throttle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://data.cdc.gov/resource/pwn4-m3yp.json?&lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&gt;=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AZ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tot_cases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2434631.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"new_cases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"3716.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tot_deaths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"33042.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"new_deaths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"39.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date_updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2023-02-23T00:00:00.000"&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;Here is the honesty check, and it is the same trap as the deprecated REST Countries body from the intro. This dataset (&lt;code&gt;pwn4-m3yp&lt;/code&gt;) returns a clean HTTP 200, but look at &lt;code&gt;date_updated&lt;/code&gt;: 2023-02-23. It is an archived COVID-19 dataset, frozen in 2023. The Socrata platform is very much alive and keyless; this particular resource is history. So the lesson generalizes: on an open-data portal, a 200 tells you the query ran, not that the data is current. Read the date field before you trust the row. Swap the resource ID for any current dataset from the data.cdc.gov catalog. Keyless requests share a throttled pool; a free Socrata app token removes the throttle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; any CDC or municipal open dataset; SoQL makes server-side filtering trivial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group C: science, the earth, and the wider world
&lt;/h2&gt;

&lt;p&gt;Three keyless endpoints that reach past US borders: species, seismic activity, and global development indicators.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. GBIF: the world's species, matched and keyed
&lt;/h3&gt;

&lt;p&gt;GBIF (the Global Biodiversity Information Facility) is an intergovernmental initiative sitting on more than two billion occurrence records. The &lt;code&gt;species/match&lt;/code&gt; endpoint resolves a name to a stable taxonomic key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.gbif.org/v1/species/match?name=Puma%20concolor"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"usageKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2435099&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"scientificName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Puma concolor (Linnaeus, 1771)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"canonicalName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Puma concolor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"SPECIES"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ACCEPTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"matchType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"EXACT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"kingdom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Animalia"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"family"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Felidae"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"genus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Puma"&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;Match the mountain lion and you get &lt;code&gt;usageKey&lt;/code&gt; 2435099 with &lt;code&gt;matchType":"EXACT"&lt;/code&gt; and &lt;code&gt;confidence":99&lt;/code&gt;. Watch &lt;code&gt;matchType&lt;/code&gt;: it also returns &lt;code&gt;FUZZY&lt;/code&gt; and &lt;code&gt;HIGHERRANK&lt;/code&gt;, so a low confidence or a fuzzy match means "I guessed," and you should not treat it as ground truth. Use the &lt;code&gt;usageKey&lt;/code&gt; to pull occurrences from the sibling endpoints. Keyless and generous; for bulk exports use the download API instead of hammering this one. Docs at techdocs.gbif.org.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; species normalization, biodiversity features, ecology and conservation data.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. USGS Earthquakes: the real-time seismic catalog
&lt;/h3&gt;

&lt;p&gt;The USGS earthquake feed is a FDSN-standard endpoint returning the seismic catalog as GeoJSON, real-time and historical. It is one of the cleanest public geospatial APIs going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&amp;amp;limit=1&amp;amp;orderby=time"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.7.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"features"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Feature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"mag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"place"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"85 km NNW of Karluk, Alaska"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"automatic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tsunami"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"geometry"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Point"&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;When I ran it, the newest event was a magnitude 0.8 near Karluk, Alaska. It is a live feed, so you will get whatever just happened, not my quake. The gotcha here is &lt;code&gt;format=geojson&lt;/code&gt;: drop it and the default response is a different format entirely, so you must set it explicitly or your JSON parser chokes on something that is not JSON. Same lesson as PubMed's &lt;code&gt;retmode&lt;/code&gt;. Filter by &lt;code&gt;starttime&lt;/code&gt;, &lt;code&gt;minmagnitude&lt;/code&gt;, and a bounding box for anything real. Keyless. Docs at earthquake.usgs.gov/fdsnws/event/1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; seismic monitoring, geospatial risk features, disaster tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. World Bank: global development indicators, keyless
&lt;/h3&gt;

&lt;p&gt;The World Bank Indicators API carries more than 16,000 series (GDP, population, poverty) for every country and year. It is the international counterweight to all the US-centric endpoints above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.worldbank.org/v2/country/us?format=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"page"&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="nl"&gt;"pages"&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="nl"&gt;"per_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&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="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"USA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"United States"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"North America"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"incomeLevel"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"High income"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"capitalCity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Washington D.C."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"-77.032"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"38.8895"&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;Read that response shape carefully, because it is the sneakiest parse trap on the list. The top-level value is an array of two things: element &lt;code&gt;[0]&lt;/code&gt; is pagination metadata, and element &lt;code&gt;[1]&lt;/code&gt; is your actual data. Index &lt;code&gt;[0]&lt;/code&gt; expecting a country and you get page counts instead, a clean 200 that hands you the wrong object. Always reach into &lt;code&gt;[1]&lt;/code&gt;. You must also pass &lt;code&gt;format=json&lt;/code&gt; or the default is XML. Keyless. Docs at datahelpdesk.worldbank.org.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; cross-country economics, development indicators, anything global and comparative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why some "free" government APIs did not make the cut
&lt;/h2&gt;

&lt;p&gt;The list stays honest by showing what it rejected. Every code below is from a live curl on July 6, 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;REST Countries v3.1&lt;/strong&gt; (&lt;code&gt;restcountries.com/v3.1/alpha/us&lt;/code&gt;, followed through its redirect) returned &lt;code&gt;HTTP 200&lt;/code&gt; with &lt;code&gt;{"success":false,"data":null,"errors":[{"message":"This API version has been deprecated..."}]}&lt;/code&gt;. Not a government API, and now a broken one. It is the cleanest example on this page of a 200 that means no, so it earns its spot as a warning rather than an entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US Census&lt;/strong&gt; (&lt;code&gt;api.census.gov/data/...&lt;/code&gt;) returned &lt;code&gt;HTTP 302&lt;/code&gt;, redirecting to a &lt;code&gt;missing_key.html&lt;/code&gt; page instead of the JSON I asked for. The keyless shape of that query no longer works; it wants a key now. Excluded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ReliefWeb&lt;/strong&gt; returned &lt;code&gt;HTTP 410&lt;/code&gt; on &lt;code&gt;v1&lt;/code&gt; (decommissioned) and &lt;code&gt;HTTP 403&lt;/code&gt; on &lt;code&gt;v2&lt;/code&gt; with "not using an approved appname." It is soft-gated behind an appname request, so it is not truly keyless. Excluded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;data.gov CKAN&lt;/strong&gt; (&lt;code&gt;catalog.data.gov/api/3/action/package_search&lt;/code&gt;) returned &lt;code&gt;HTTP 404 Not Found&lt;/code&gt;. The central catalog action API was not answering; go to the underlying agency's Socrata or API directly, as with CDC above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NASA, FEC, Congress.gov, GovInfo&lt;/strong&gt; all require a free &lt;code&gt;DEMO_KEY&lt;/code&gt; or registration. Excellent APIs, but they fail the "no key, no signup" bar of this roundup by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honorable mentions that are keyless and work:&lt;/strong&gt; NWS (&lt;code&gt;api.weather.gov&lt;/code&gt;) and USGS Water Services both returned clean keyless 200s. I left weather off because I covered it in a previous roundup, and kept USGS to one entry so a single provider does not dominate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping these right under real traffic
&lt;/h2&gt;

&lt;p&gt;Finding the endpoint is the easy 10 percent. Here is the other 90, distilled from the failures above and from years of watching enrichment data lie quietly in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parse the payload, not the status code.&lt;/strong&gt; This is the whole post. REST Countries returned 200 with a deprecation notice. The CDC dataset returned 200 with data frozen in 2023. Census returned a 302 to a key page. A status check would have waved all three through. This is the single pattern I carry over from 2,190 production runs, honestly and narrowly: across those runs, enrichment sources fail as a confident 200 with a wrong body far more often than as an honest error. Assert on a field you expect (&lt;code&gt;success&lt;/code&gt;, a recent date, a non-empty result), never on &lt;code&gt;200&lt;/code&gt; alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The User-Agent is the real gate, not a key.&lt;/strong&gt; Almost none of these want a key. What they quietly reward is a descriptive User-Agent and polite pacing. USAspending, GBIF, Treasury, and the World Bank all answered a bare curl, but government services increasingly ask you to identify yourself, and a well-behaved agent is what keeps you unblocked. No key does not mean no etiquette.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Respect the real limits, because some are enforced.&lt;/strong&gt; openFDA gives you 240 requests per minute and 1,000 per day per IP with no key. PubMed enforces 3 per second. CDC's Socrata pool is throttled without an app token. These are not decorative. Cache aggressively, and get the free key or token before you batch anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learn the three parse traps by heart.&lt;/strong&gt; World Bank wraps your data as element &lt;code&gt;[1]&lt;/code&gt; behind a metadata &lt;code&gt;[0]&lt;/code&gt;. Treasury's &lt;code&gt;page[size]&lt;/code&gt; needs curl's &lt;code&gt;-g&lt;/code&gt; flag. USGS and PubMed hand you XML unless you set &lt;code&gt;format=geojson&lt;/code&gt; and &lt;code&gt;retmode=json&lt;/code&gt;. Every one of these produces a clean 200 and a broken parse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The licensing is a genuine gift.&lt;/strong&gt; Data produced by the US federal government is public domain under 17 U.S.C. Section 105, so USAspending, Treasury, FDA, USGS, and Federal Register data can go straight into a commercial product. World Bank and GBIF use permissive licenses; check the specific dataset. This is rarer than it sounds in the API world.&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the output is always a table of rows that need trustworthy enrichment columns. Location was the &lt;a href="https://blog.spinov.online/blog/free-geocoding-apis-no-key/" rel="noopener noreferrer"&gt;geocoding column&lt;/a&gt;. Company identity was &lt;a href="https://blog.spinov.online/blog/free-company-data-apis-no-key/" rel="noopener noreferrer"&gt;the registry column&lt;/a&gt;. Authoritative facts (spending, drug safety, debt) are this one. Same keyless, no-card layer, different question, and the same failure mode every single time: a 200 that is empty, stale, or deprecated, never an honest 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are government APIs really free with no API key?&lt;/strong&gt;&lt;br&gt;
Yes. Most US federal APIs (USAspending, Federal Register, openFDA, Treasury FiscalData) and intergovernmental ones (World Bank, GBIF) return JSON with no key and no signup. A few (NASA, FEC, Congress.gov) require a free key, and those are excluded from this list by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the catch with keyless government APIs?&lt;/strong&gt;&lt;br&gt;
Polite-use throttling and a User-Agent gate rather than a key, plus real per-IP limits on a few (openFDA 1,000/day, PubMed 3/second). And the big one: HTTP 200 does not guarantee usable data. No key does not mean no etiquette, and it does not mean unlimited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use US government data in a commercial product?&lt;/strong&gt;&lt;br&gt;
Data produced by the US federal government is public domain under 17 U.S.C. Section 105, so it can go into a commercial product. World Bank and GBIF use permissive licenses; always check the specific dataset's terms before you ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did an API return HTTP 200 but no usable data?&lt;/strong&gt;&lt;br&gt;
Because 200 means the request was handled, not that the answer is right. REST Countries v3.1 returns a 200 whose body says "deprecated." A CDC Socrata dataset can return a 200 full of data frozen in 2023. Always assert on a field you expect, not on the status code alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which free government API should I start with?&lt;/strong&gt;&lt;br&gt;
For US federal spending, USAspending. For the national debt and Treasury data, FiscalData. For drug and device safety, openFDA. For anything global and comparative, the World Bank. All four are keyless with no signup.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every API above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (HTTP 200, real response) on July 6, 2026 before publishing; responses are trimmed, not paraphrased. Two endpoints are live feeds (Federal Register and USGS Earthquakes), so &lt;code&gt;results[0]&lt;/code&gt; moves; run the curl yourself for the current value. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next batch of keyless APIs I test for the enrichment layer. And tell me: which government or open-data API has quietly handed you a clean 200 with a wrong body, and what field finally gave the bug away? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>opendata</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>9 Free Public Holiday &amp; Time APIs With No Key (2026)</title>
      <dc:creator>Alex Spinov </dc:creator>
      <pubDate>Mon, 06 Jul 2026 02:36:34 +0000</pubDate>
      <link>https://dev.to/0012303/9-free-public-holiday-time-apis-with-no-key-2026-26fp</link>
      <guid>https://dev.to/0012303/9-free-public-holiday-time-apis-with-no-key-2026-26fp</guid>
      <description>&lt;p&gt;Finding a free holiday or time API is the easy 10 percent. The trap is the other 90: these APIs quietly disagree, and each one fails in a way that looks like success. On July 4, 2026 I asked sunrise-sunset.org for New York's sunset and it told me 12:32 AM. Not an error. A clean HTTP 200 with a value that reads like a bug. I lost twenty minutes before I scrolled down and saw &lt;code&gt;"tzid":"UTC"&lt;/code&gt; sitting in the same response.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free public holiday or time API&lt;/strong&gt; answers a temporal question (is this a business day, what is the local time, what is this date in another calendar) without a key, a signup, or a card. The nine below need none of those. I re-verified each with a live &lt;code&gt;curl&lt;/code&gt; on July 4, 2026 (HTTP 200), and every response you see is the real output, trimmed for length, not paraphrased. If you build schedulers, billing and SLA calendars, booking flows, HR "next working day" logic, or i18n date displays, these are the lookups I reach for. And every one of them has a field that will silently lie to you if you skip it.&lt;/p&gt;

&lt;p&gt;Let me be straight about the scope. I am not claiming I ran a holiday resolver across all 2,190 runs. That would be a lie. I called each endpoint once on July 4, 2026 with a single request. The only thing I am generalizing from 2,190 runs is narrower: enrichment data fails as a 200 with a wrong body far more often than as a clean error, which is exactly why the field-level gotchas below matter more than the endpoint list.&lt;/p&gt;

&lt;p&gt;Here is the full set at a glance, grouped by the job each one does, then a section each.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;th&gt;Example call&lt;/th&gt;
&lt;th&gt;No key?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Nager.Date&lt;/td&gt;
&lt;td&gt;Country + year to public holidays&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET date.nager.at/api/v3/PublicHolidays/2026/US&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;OpenHolidays&lt;/td&gt;
&lt;td&gt;EU country to holidays with subdivisions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET openholidaysapi.org/PublicHolidays?countryIsoCode=DE…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (EU only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;gov.uk Bank Holidays&lt;/td&gt;
&lt;td&gt;UK bank holidays, three divisions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET gov.uk/bank-holidays.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (UK only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Enrico / Kayaposoft&lt;/td&gt;
&lt;td&gt;Country + year holidays (community data)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET kayaposoft.com/enrico/json/v2.0/?action=getHolidaysForYear…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;TimeAPI.io&lt;/td&gt;
&lt;td&gt;IANA zone to current time + DST flag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET timeapi.io/api/time/current/zone?timeZone=America/New_York&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;WorldClockAPI&lt;/td&gt;
&lt;td&gt;Authoritative UTC "now"&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET worldclockapi.com/api/json/utc/now&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (HTTP only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Hebcal&lt;/td&gt;
&lt;td&gt;Gregorian to Hebrew date + Torah portion&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET hebcal.com/converter?…g2h=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Aladhan&lt;/td&gt;
&lt;td&gt;Gregorian to Hijri date&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.aladhan.com/v1/gToH/04-07-2026&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Sunrise-Sunset&lt;/td&gt;
&lt;td&gt;Lat/lng to sunrise, sunset, day length&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET api.sunrise-sunset.org/json?lat=40.71&amp;amp;lng=-74.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (UTC default)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is what makes this a list and not one API. There is no single "what day is it" service. July 4, 2026 is a Saturday everywhere, but ask what that date means and the answers scatter: 19 Tamuz 5786 to Hebcal, 19 Muharram 1448 to Aladhan, one lone holiday (Juneteenth, weeks earlier) in Enrico's US feed, and a New York sunset that is either 12:32 AM or 8:32 PM depending on one query parameter. Which source you hit depends on the question. Reading its fields correctly is the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group A: public holidays (fill a calendar, check a business day)
&lt;/h2&gt;

&lt;p&gt;Four keyless holiday sources, four different coverage models, and four different ways to get burned.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Nager.Date: country and year to a holiday list
&lt;/h3&gt;

&lt;p&gt;Hand Nager a two-letter country code and a year and it returns that country's public holidays. It is the broadest keyless holiday source I found, and the one I default to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://date.nager.at/api/v3/PublicHolidays/2026/US"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"localName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"New Year's Day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"New Year's Day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"global"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"counties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"Bank"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-02-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"localName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Lincoln's Birthday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Lincoln's Birthday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"global"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"counties"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"US-CA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-CT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-IL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-IN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-KY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-MI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-NY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-MO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"US-OH"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Observance"&lt;/span&gt;&lt;span class="p"&gt;]}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New Year's Day comes back with &lt;code&gt;"global":true&lt;/code&gt;. Lincoln's Birthday comes back with &lt;code&gt;"global":false&lt;/code&gt; and a &lt;code&gt;counties&lt;/code&gt; array of nine states. That is the gotcha, and it is a live one: &lt;strong&gt;state-only holidays sit in the same array as nationwide ones&lt;/strong&gt;. Filter on &lt;code&gt;"global":true&lt;/code&gt; (or read &lt;code&gt;counties&lt;/code&gt;) or you will show a California observance to a user in Texas. While reading the raw body I also caught Good Friday listed twice, once per set of states that observe it, so a naive count is wrong too. There is a companion &lt;code&gt;GET /api/v3/AvailableCountries&lt;/code&gt; that lists every supported code, also keyless. No published hard rate limit; it is an open-source project, so cache and be polite. Verify the current terms at date.nager.at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; any country, when you want one consistent shape and can filter on &lt;code&gt;global&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. OpenHolidays: European holidays with subdivision detail
&lt;/h3&gt;

&lt;p&gt;OpenHolidays is the one to reach for inside the EU, because it carries subdivision-level detail that Nager flattens. Its catch cost me an afternoon once, so I will lead with it: &lt;strong&gt;it is European only, and it tells you that with an empty 200, not a 404.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://openholidaysapi.org/PublicHolidays?countryIsoCode=DE&amp;amp;languageIsoCode=EN&amp;amp;validFrom=2026-01-01&amp;amp;validTo=2026-12-31"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"startDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"endDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"EN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"New Year's Day"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"nationwide"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"startDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-06"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"EN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Epiphany"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"nationwide"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subdivisions"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"DE-ST"&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"DE-BW"&lt;/span&gt;&lt;span class="p"&gt;},{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"DE-BY"&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;For Germany you get Epiphany flagged &lt;code&gt;"nationwide":false&lt;/code&gt; with the exact states (Saxony-Anhalt, Baden-Wurttemberg, Bavaria) that observe it. Now watch the failure. Change &lt;code&gt;countryIsoCode&lt;/code&gt; to &lt;code&gt;US&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://openholidaysapi.org/PublicHolidays?countryIsoCode=US&amp;amp;languageIsoCode=EN&amp;amp;validFrom=2026-01-01&amp;amp;validTo=2026-12-31"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is an HTTP 200 with an empty array. I only found this the first time because my calendar view rendered blank and I assumed my own code was broken. It was 200 the whole time; the coverage just stops at Europe's edge. The endpoint also requires both &lt;code&gt;validFrom&lt;/code&gt; and &lt;code&gt;validTo&lt;/code&gt;, or it will not answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; EU countries where you need which region observes what. Not for the US or Asia.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. gov.uk Bank Holidays: the UK, straight from the government
&lt;/h3&gt;

&lt;p&gt;The UK government publishes its bank holidays as one static JSON file. No key, effectively unlimited, and it is the primary source rather than a scrape of one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.gov.uk/bank-holidays.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"england-and-wales"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"division"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"england-and-wales"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"New Year's Day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2019-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"notes"&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="nl"&gt;"bunting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"New Year's Day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"notes"&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="nl"&gt;"bunting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Good Friday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-04-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"notes"&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="nl"&gt;"bunting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Summer bank holiday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-08-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"notes"&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="nl"&gt;"bunting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scotland"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"division"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"scotland"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2nd January"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-01-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"bunting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"St Andrew's Day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-11-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"bunting"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"northern-ireland"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="err"&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;Two things to read carefully. First, the array runs all the way back to 2019-01-01, so &lt;strong&gt;filter by year&lt;/strong&gt; or you will process seven years of history. Second, this is one endpoint holding three different calendars: &lt;code&gt;england-and-wales&lt;/code&gt;, &lt;code&gt;scotland&lt;/code&gt;, and &lt;code&gt;northern-ireland&lt;/code&gt; genuinely differ. Scotland has 2 January and St Andrew's Day; England does not. Pick the division your user lives in. The &lt;code&gt;bunting&lt;/code&gt; boolean is a real field, by the way, telling you whether flags are traditionally flown. Confirmed 2026 dates in my pull included 2026-01-01, 2026-04-03, 2026-05-04, 2026-05-25, 2026-08-31, and 2026-12-25.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; anything UK. Just pick the right division.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Enrico / Kayaposoft: wide country coverage, thin in places
&lt;/h3&gt;

&lt;p&gt;Enrico covers a long list of countries from community-maintained data, which is its strength and its weakness. I include it with a warning attached, because it taught me a lesson worth passing on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://kayaposoft.com/enrico/json/v2.0/?action=getHolidaysForYear&amp;amp;year=2026&amp;amp;country=usa&amp;amp;holidayType=public_holiday"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"month"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"dayOfWeek"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"lang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Juneteenth National Independence Day"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"holidayType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"public_holiday"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire response. One holiday for the whole of 2026 for the United States. Not one page, one holiday. I almost shipped that as "US holidays for 2026" before cross-checking against Nager, which returned the full set. &lt;strong&gt;Community data is sparse in spots&lt;/strong&gt;, so treat Enrico as breadth-of-countries, not depth, and never trust a suspiciously short list from any single holiday source. Verify current coverage at kayaposoft.com/enrico.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; reaching a country the others do not cover, with a cross-check in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group B: time zones and current time (schedulers, display, DST math)
&lt;/h2&gt;

&lt;p&gt;Two keyless services answer "what time is it, really, over there," and one of them does the DST arithmetic you would otherwise get wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. TimeAPI.io: current time for any IANA zone, with a DST flag
&lt;/h3&gt;

&lt;p&gt;Give TimeAPI.io an IANA time zone name and it returns the current local time plus a &lt;code&gt;dstActive&lt;/code&gt; flag. That flag is the reason to call it instead of hardcoding an offset.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://timeapi.io/api/time/current/zone?timeZone=America/New_York"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"month"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"hour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"minute"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"dateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-04T14:26:30.2654687"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"America/New_York"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"dayOfWeek"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Saturday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"dstActive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On July 4 it returned &lt;code&gt;"dstActive":true&lt;/code&gt;, because New York is on daylight time in summer. If you had stored a fixed UTC offset for "New York" you would be an hour off for half the year. The service also does zone conversion over POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://timeapi.io/api/conversion/converttimezone"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"fromTimeZone":"America/New_York","dateTime":"2026-07-04 14:00:00","toTimeZone":"Europe/London","dstAmbiguity":""}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"fromTimezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"America/New_York"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"fromDateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-04T14:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"toTimeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Europe/London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"conversionResult"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"dateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-04T19:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Europe/London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"dstActive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2:00 PM in New York maps to 7:00 PM in London, with both ends flagged as on daylight time. One honest note: this is a live clock, so the hour and minute above have already moved. Run the curl yourself; free clock endpoints drift by the second and the shape is the point, not my timestamp. No key on the free tier; be reasonable and verify current limits at timeapi.io.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; any scheduler or display that must respect DST for a named zone.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. WorldClockAPI: a plain authoritative "now"
&lt;/h3&gt;

&lt;p&gt;WorldClockAPI is the minimalist option. It hands you an authoritative UTC "now" and a few useful extras, and nothing more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"http://worldclockapi.com/api/json/utc/now"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"$id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"currentDateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-04T18:26Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"utcOffset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"00:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"isDayLightSavingsTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"dayOfTheWeek"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Saturday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"timeZoneName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"UTC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"currentFileTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;134276632058538289&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"ordinalDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-185"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"serviceResponse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats. First, the endpoint I hit is &lt;strong&gt;HTTP only&lt;/strong&gt;, no TLS, so do not send anything sensitive and do not depend on it inside a strict HTTPS-only pipeline. Second, it is deliberately thin; there is no rich zone math here. What it is good for is a quick sanity anchor ("what is the authoritative now?") plus the extras: &lt;code&gt;ordinalDate&lt;/code&gt; gives you &lt;code&gt;2026-185&lt;/code&gt; (day 185 of the year) and &lt;code&gt;currentFileTime&lt;/code&gt; gives you a Windows FILETIME if you need one. Same live-clock caveat as above: re-run it, the timestamp has moved. Verify current status at worldclockapi.com.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; a lightweight UTC anchor and day-of-year, when you do not need per-zone conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group C: alternative calendars (i18n and religious calendars)
&lt;/h2&gt;

&lt;p&gt;Two keyless converters turn a Gregorian date into a Hebrew or Islamic one. Both are signup-free, and both have a field that decides whether you are right or off by a day.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Hebcal: Gregorian to Hebrew, plus the weekly Torah portion
&lt;/h3&gt;

&lt;p&gt;Hebcal converts a Gregorian date to the Hebrew calendar and throws in the week's Torah reading for free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.hebcal.com/converter?cfg=json&amp;amp;gy=2026&amp;amp;gm=7&amp;amp;gd=4&amp;amp;g2h=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"gy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"gm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"gd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"afterSunset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"hy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5786&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"hm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Tamuz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"hd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"hebrew"&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="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Parashat Pinchas"&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;July 4, 2026 is 19 Tamuz 5786, in the week of Parashat Pinchas. The field that matters is &lt;code&gt;afterSunset&lt;/code&gt;. The Hebrew date rolls over at sunset, not midnight, so if your event happens in the evening you must pass &lt;code&gt;gs=on&lt;/code&gt; (or set &lt;code&gt;afterSunset&lt;/code&gt; correctly) or your conversion lands a day early. Miss that and the bug only shows up for evening timestamps, which is a miserable one to debug. Keyless; cache and be polite. Verify at hebcal.com.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; rendering or converting Hebrew dates, especially near sunset.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Aladhan: Gregorian to Hijri, and it tells you its method
&lt;/h3&gt;

&lt;p&gt;Aladhan converts a Gregorian date to the Islamic (Hijri) calendar. Its honesty is the feature: it tells you which calculation method it used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.aladhan.com/v1/gToH/04-07-2026"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"hijri"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"19-01-1448"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"19"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"month"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"number"&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="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Muharram"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"1448"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"designation"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"abbreviated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AH"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"HJCoSA"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"gregorian"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"04-07-2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"weekday"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Saturday"&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;July 4, 2026 comes back as 19 Muharram 1448 AH, and the response labels its &lt;code&gt;method&lt;/code&gt; as &lt;code&gt;HJCoSA&lt;/code&gt;. Here is the caveat that keeps you honest: the Hijri calendar is &lt;strong&gt;calculation-method dependent&lt;/strong&gt;, and month length flips between 29 and 30 days. The API is transparent about which method it applied, so do not treat a single call as the one canonical answer for religious observance, which may depend on local moon sighting. Same date, two calendars: Aladhan says 19 Muharram, Hebcal says 19 Tamuz, and both are correct in their own system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; displaying or storing Hijri dates, with the method recorded alongside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group D: solar day-length (the bonus temporal utility)
&lt;/h2&gt;

&lt;p&gt;One more keyless endpoint, and it is the one that opened this post.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Sunrise-Sunset: sunrise, sunset, and the UTC trap
&lt;/h3&gt;

&lt;p&gt;Sunrise-sunset.org returns sunrise, sunset, solar noon, and day length for any latitude and longitude. It is genuinely useful and it hides the single best example of a silent-wrong response on this whole list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.sunrise-sunset.org/json?lat=40.7128&amp;amp;lng=-74.0060&amp;amp;date=2026-07-04"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"sunrise"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"9:28:49 AM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"sunset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"12:32:13 AM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"solar_noon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"5:00:31 PM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"day_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"15:03:24"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tzid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"UTC"&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;A sunset at 12:32 AM. For New York in July, that is nonsense on its face, and it is a clean HTTP 200. The tell is &lt;code&gt;"tzid":"UTC"&lt;/code&gt;: the times are in UTC by default, and New York in summer is four hours behind. Pass the zone and it snaps into place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# runnable, read-only&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.sunrise-sunset.org/json?lat=40.7128&amp;amp;lng=-74.0060&amp;amp;date=2026-07-04&amp;amp;tzid=America/New_York"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"sunrise"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"5:28:49 AM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"sunset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"8:32:13 PM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"day_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"15:03:24"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tzid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"America/New_York"&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;Now sunrise is 5:28 AM and sunset is 8:32 PM, which is a real July day in New York. Note &lt;code&gt;day_length&lt;/code&gt; was correct both times (15 hours, 3 minutes); only the wall-clock fields shifted. Pass &lt;code&gt;formatted=0&lt;/code&gt; if you want ISO 8601 timestamps instead of the "AM/PM" strings. This is the failure mode the whole post is about: not a 500, not a 404, just a 200 with values that look broken until you read one field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use it:&lt;/strong&gt; daylight-aware scheduling, solar features, "golden hour" logic. Always send &lt;code&gt;tzid&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why some "free" holiday and time APIs did not make the cut
&lt;/h2&gt;

&lt;p&gt;The list stays honest by showing what it rejected. Every code below is from a live curl on July 4, 2026.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calendarific&lt;/strong&gt; (&lt;code&gt;calendarific.com/api/v2/holidays?country=US&amp;amp;year=2026&lt;/code&gt;) returned &lt;code&gt;HTTP 401&lt;/code&gt; &lt;code&gt;{"meta":{"code":401,"error_type":"auth failed","error_detail":"Missing or invalid api credentials..."}}&lt;/code&gt;. It is a popular holiday API, but it wants a key. Excluded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WorldTimeAPI.org&lt;/strong&gt; (&lt;code&gt;worldtimeapi.org/api/timezone/America/New_York&lt;/code&gt;) was the classic keyless time option for years. It returned &lt;code&gt;HTTP 000&lt;/code&gt;, connection failed, on repeated retries over both HTTPS and HTTP, including a fresh re-curl right before publishing. It is unreachable at test time, so it stays off the list. Use TimeAPI.io or WorldClockAPI instead, and if worldtimeapi.org comes back, verify it yourself before wiring it in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ipgeolocation.io time API&lt;/strong&gt; is key-gated per its docs, so it never made the shortlist for a "no key" roundup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping these right under real traffic
&lt;/h2&gt;

&lt;p&gt;The endpoint is the easy part. Reading the fields is the job. A few habits that have saved me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filter on the scope field, every time.&lt;/strong&gt; Nager gives you &lt;code&gt;global&lt;/code&gt;. OpenHolidays gives you &lt;code&gt;nationwide&lt;/code&gt; and &lt;code&gt;subdivisions&lt;/code&gt;. gov.uk gives you three divisions. Skip that field and you will show a regional holiday to a national audience, or count Good Friday twice. The scope field is the answer, full stop. Read it every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always pass the zone.&lt;/strong&gt; Sunrise-Sunset defaults to UTC. Hebcal rolls at sunset via &lt;code&gt;afterSunset&lt;/code&gt;. Aladhan records a &lt;code&gt;method&lt;/code&gt;. Time is never "just a number," and the query parameter you omit is the bug you ship. Send &lt;code&gt;tzid&lt;/code&gt;, send &lt;code&gt;gs&lt;/code&gt;, read &lt;code&gt;method&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never trust a single temporal source blind.&lt;/strong&gt; Enrico handed me one US holiday for a year. OpenHolidays handed me an empty array for the US. Both were HTTP 200. Cross-check a suspiciously short or empty list against a second source before it reaches a user. This is the same silent-wrong trap I hit with keyless geocoding: the danger is not the honest error, it is the confident 200 with the wrong body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache what is stable, re-fetch what moves.&lt;/strong&gt; Holiday lists and calendar conversions for a fixed date do not change, so cache them hard. The two live clocks (TimeAPI.io, WorldClockAPI) move by the second, so never cache "now." Mixing those two policies up is its own class of bug.&lt;/p&gt;

&lt;p&gt;For context, not a claim of scale: I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production, and the output is always a list of rows that need enrichment columns. Location was the &lt;a href="https://blog.spinov.online/blog/free-geocoding-apis-no-key/" rel="noopener noreferrer"&gt;geocoding column&lt;/a&gt;. Security posture was &lt;a href="https://blog.spinov.online/blog/free-cve-vulnerability-apis-no-key/" rel="noopener noreferrer"&gt;the CVE column&lt;/a&gt;. Time and calendar is this one. Same keyless, no-card enrichment layer, different question, and the same failure mode every time: a 200 that is empty or wrong, never an honest 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the best free public holiday API with no API key?&lt;/strong&gt;&lt;br&gt;
It depends on coverage. Nager.Date is the broadest for country-plus-year lists worldwide, OpenHolidays gives the richest EU subdivision detail (but returns an empty 200 outside Europe), and gov.uk covers the UK straight from the government. All are keyless, no signup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a free time zone API without an API key?&lt;/strong&gt;&lt;br&gt;
Yes. TimeAPI.io returns the current time for any IANA zone with a &lt;code&gt;dstActive&lt;/code&gt; flag and does zone conversion over POST. WorldClockAPI gives a plain authoritative UTC "now" (over HTTP only). Both are keyless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the holiday API return an empty list for the US?&lt;/strong&gt;&lt;br&gt;
OpenHolidays covers European countries only. A request with &lt;code&gt;countryIsoCode=US&lt;/code&gt; returns HTTP 200 with an empty array &lt;code&gt;[]&lt;/code&gt;, not a 404, so it looks like "no holidays" when it means "no coverage." Use Nager.Date or Enrico for the US.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I get sunrise and sunset times for free?&lt;/strong&gt;&lt;br&gt;
sunrise-sunset.org, keyless, by latitude and longitude. The times are UTC by default, so a New York sunset can read as "12:32 AM." Pass &lt;code&gt;tzid=America/New_York&lt;/code&gt; for local time, and &lt;code&gt;formatted=0&lt;/code&gt; for ISO timestamps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I convert a date to the Hebrew or Islamic calendar without a key?&lt;/strong&gt;&lt;br&gt;
Yes. Hebcal converts Gregorian to Hebrew (watch the &lt;code&gt;afterSunset&lt;/code&gt; field, since the Hebrew day rolls at sunset) and adds the weekly Torah portion. Aladhan converts Gregorian to Hijri and records the calculation &lt;code&gt;method&lt;/code&gt; it used. Both are keyless.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every API above was re-verified with a live &lt;code&gt;curl&lt;/code&gt; (HTTP 200, real response) on July 4, 2026 before publishing; responses are trimmed, not paraphrased. Holiday and calendar values for a fixed date are stable, but the two live clocks (TimeAPI.io and WorldClockAPI) move by the second, so run the curl yourself before you wire one in. Drafted with an AI assistant, fact-checked and edited by me.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow for the next batch of keyless tools I test for the enrichment layer. And tell me: which temporal source has quietly lied to you with a clean 200, and what field finally gave the bug away? I read every comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
