<?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: Abdulswamad Rama </title>
    <description>The latest articles on DEV Community by Abdulswamad Rama  (@rsvlim).</description>
    <link>https://dev.to/rsvlim</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%2F2037603%2F08ab9054-275a-4d47-b2f3-70e08ba1412a.jpeg</url>
      <title>DEV Community: Abdulswamad Rama </title>
      <link>https://dev.to/rsvlim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rsvlim"/>
    <language>en</language>
    <item>
      <title>New Relic's Python agent rejected the key its own infra agent was using</title>
      <dc:creator>Abdulswamad Rama </dc:creator>
      <pubDate>Wed, 03 Jun 2026 08:32:20 +0000</pubDate>
      <link>https://dev.to/rsvlim/new-relics-python-agent-rejected-the-key-its-own-infra-agent-was-using-4dj3</link>
      <guid>https://dev.to/rsvlim/new-relics-python-agent-rejected-the-key-its-own-infra-agent-was-using-4dj3</guid>
      <description>&lt;p&gt;The infra agent worked immediately. The Python APM agent spent an hour rejecting the same license key that was already authenticating the infra agent. Same account. Same VPS. Same key. One worked, one didn't.&lt;/p&gt;

&lt;p&gt;That's the story.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting New Relic free via GitHub Student Dev Pack
&lt;/h2&gt;

&lt;p&gt;If you have a &lt;code&gt;.edu&lt;/code&gt; email or a GitHub Student account, the &lt;a href="https://education.github.com/pack" rel="noopener noreferrer"&gt;GitHub Student Developer Pack&lt;/a&gt; includes a New Relic Pro subscription — the full observability platform, not a trial.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp3xl5tb47nt814ep4qhj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp3xl5tb47nt814ep4qhj.png" alt="GitHub Student Developer Pack page showing the New Relic offer card" width="474" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To claim it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://education.github.com/pack" rel="noopener noreferrer"&gt;education.github.com/pack&lt;/a&gt; and verify your student status if you haven't already.&lt;/li&gt;
&lt;li&gt;Search for "New Relic" in the pack offers.&lt;/li&gt;
&lt;li&gt;Click the offer — it redirects to New Relic's partner page. Or simply &lt;a href="https://newrelic.com/students" rel="noopener noreferrer"&gt;sign up directly&lt;/a&gt; if you're logged in to your existing GitHub account &lt;/li&gt;
&lt;li&gt;Create a New Relic account via GitHub.&lt;/li&gt;
&lt;li&gt;The Pro plan activates automatically. Your account is provisioned in one of two regions: US or EU. If you're outside North America, you'll likely get EU (&lt;code&gt;one.eu.newrelic.com&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Region matters later.&lt;/p&gt;




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

&lt;p&gt;The target: a Flask app running in Docker on a single VPS. Stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flask + Gunicorn (2 workers, 2 threads)&lt;/li&gt;
&lt;li&gt;PostgreSQL 16 in a sidecar container&lt;/li&gt;
&lt;li&gt;Nginx in front, TLS via Let's Encrypt&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; as the orchestrator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure metrics&lt;/strong&gt; — CPU, memory, disk on the host&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log forwarding&lt;/strong&gt; — Flask app logs into New Relic Logs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python APM&lt;/strong&gt; — request traces, error rates, slow transaction data&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 1: Install the infra agent
&lt;/h2&gt;

&lt;p&gt;New Relic's guided install gives you a one-liner. For an EU account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-Ls&lt;/span&gt; https://download.newrelic.com/install/newrelic-cli/scripts/install.sh | bash &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;NEW_RELIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your-api-key&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nv"&gt;NEW_RELIC_ACCOUNT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your-account-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nv"&gt;NEW_RELIC_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;EU &lt;span class="se"&gt;\&lt;/span&gt;
       /usr/local/bin/newrelic &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer is interactive. It detects your OS, offers integrations (PostgreSQL, nginx, etc.), and installs &lt;code&gt;newrelic-infra&lt;/code&gt; as a systemd service, Fluent Bit for log forwarding, and Golden Signal alert policies.&lt;/p&gt;

&lt;p&gt;When it asked for PostgreSQL credentials, I skipped — PostgreSQL runs inside Docker with no port exposed to the host. The infra agent runs on the host and can't reach it. Skip it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8bxxgyuy7k9czj7ooxbu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8bxxgyuy7k9czj7ooxbu.png" alt="Terminal showing " width="800" height="627"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After install, &lt;code&gt;sudo systemctl status newrelic-infra&lt;/code&gt; shows active. The license key is stored at &lt;code&gt;/etc/newrelic-infra.yml&lt;/code&gt; — you'll need it later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Add the Python APM agent to Flask
&lt;/h2&gt;

&lt;p&gt;The plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;newrelic&amp;gt;=9.0&lt;/code&gt; to &lt;code&gt;pyproject.toml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Drop a &lt;code&gt;newrelic.ini&lt;/code&gt; config file in the repo&lt;/li&gt;
&lt;li&gt;Change the Gunicorn CMD to &lt;code&gt;newrelic-admin run-program gunicorn ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pass the license key via environment variable&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The config file
&lt;/h3&gt;

&lt;p&gt;The New Relic setup wizard generates a &lt;code&gt;newrelic.ini&lt;/code&gt;. Strip the license key out — don't commit it to git — and keep the meaningful settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[newrelic]&lt;/span&gt;
&lt;span class="py"&gt;app_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;your-app-name&lt;/span&gt;

&lt;span class="py"&gt;monitor_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;log_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;stdout&lt;/span&gt;
&lt;span class="py"&gt;log_level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;info&lt;/span&gt;

&lt;span class="py"&gt;distributed_tracing.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;otlp_host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;otlp.eu01.nr-data.net&lt;/span&gt;

&lt;span class="py"&gt;transaction_tracer.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;transaction_tracer.record_sql&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;obfuscated&lt;/span&gt;

&lt;span class="py"&gt;error_collector.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;application_logging.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;application_logging.forwarding.enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[newrelic:production]&lt;/span&gt;
&lt;span class="py"&gt;monitor_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not put &lt;code&gt;license_key =&lt;/code&gt; in this file — not even empty. An explicit empty value is ambiguous. The &lt;code&gt;NEW_RELIC_LICENSE_KEY&lt;/code&gt; env var handles it cleanly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dockerfile change
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["newrelic-admin", "run-program", "gunicorn", \&lt;/span&gt;
     "-w", "2", "--threads", "2", "--timeout", "120", \
     "-b", "0.0.0.0:5000", "server:app"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  docker-compose.yml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;NEW_RELIC_CONFIG_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/home/appuser/app/newrelic.ini&lt;/span&gt;
  &lt;span class="na"&gt;NEW_RELIC_ENVIRONMENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;NEW_RELIC_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;collector.eu01.nr-data.net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The license key lives in a gitignored &lt;code&gt;.env&lt;/code&gt; on the server, loaded via &lt;code&gt;env_file:&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="nv"&gt;NEW_RELIC_LICENSE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu01x&amp;lt;your-key&amp;gt;NRAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: The license key that didn't work
&lt;/h2&gt;

&lt;p&gt;Deployed. Checked logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;newrelic.core.agent INFO - New Relic Python Agent (13.1.0)
newrelic.core.agent_protocol ERROR - Data collector is indicating that
an incorrect license key has been supplied by the agent.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried three keys:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The one generated by the New Relic Python setup wizard — rejected&lt;/li&gt;
&lt;li&gt;The "Original account license" key from the API Keys admin page — rejected&lt;/li&gt;
&lt;li&gt;A fresh INGEST - LICENSE key created manually in the UI — rejected&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The infra agent was running fine on the same host the whole time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy2h0qr0fj7zflfywui1t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy2h0qr0fj7zflfywui1t.png" alt="New Relic API Keys page showing the INGEST - LICENSE key rows with masked values" width="800" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on copying keys from New Relic:&lt;/strong&gt; the table masks all key values as &lt;code&gt;eu01×xxx*****&lt;/code&gt;. The &lt;code&gt;...&lt;/code&gt; row menu has "Copy key ID" (the ID, not the value) and "Edit" shows the value masked in a form. The only way to get the full value is to create a new key and copy it at creation — shown once, then masked permanently.&lt;/p&gt;




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

&lt;p&gt;The infra agent was installed with &lt;code&gt;NEW_RELIC_REGION=EU&lt;/code&gt; explicitly — it knew to send data to the EU collector. The Python agent had no such directive. It's supposed to auto-detect the EU region from the &lt;code&gt;eu01&lt;/code&gt; license key prefix, but version 13.1.0 wasn't doing it reliably.&lt;/p&gt;

&lt;p&gt;The Python agent was connecting to the US APM collector (&lt;code&gt;collector.newrelic.com&lt;/code&gt;) with an EU-region key. The US collector correctly said: this key doesn't belong here.&lt;/p&gt;

&lt;p&gt;Fix: one env var.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;NEW_RELIC_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;collector.eu01.nr-data.net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redeployed. Logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;newrelic.core.agent INFO - New Relic Python Agent (13.1.0)
newrelic.core.agent_protocol INFO - Reporting to:
  https://one.eu.newrelic.com/redirect/entity/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp8qkf8tpfmbtdmpid5s1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp8qkf8tpfmbtdmpid5s1.png" alt="New Relic APM test connection screen showing Python agent and infrastructure both successful" width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What you end up with
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure tab&lt;/strong&gt; — CPU, memory, disk, network I/O on the host. The &lt;code&gt;newrelic-infra&lt;/code&gt; service uses about 48MB RSS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs tab&lt;/strong&gt; — Flask app logs, Gunicorn worker logs, all searchable. Filtering by &lt;code&gt;level:error&lt;/code&gt; across everything in one query is useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;APM &amp;amp; Services&lt;/strong&gt; — request throughput per route, response time distribution, error rate, slow transaction traces, and Python exception capture with full stack traces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Golden Signal Alerts&lt;/strong&gt; — pre-configured policies that email you if CPU spikes, error rate jumps, throughput drops, or response time degrades.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fms7bytcwyqva4a2j2qgk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fms7bytcwyqva4a2j2qgk.png" alt="New Relic APM &amp;amp; Services showing web-app with throughput data" width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  EU account checklist
&lt;/h2&gt;

&lt;p&gt;If your New Relic account is EU (&lt;code&gt;one.eu.newrelic.com&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEW_RELIC_LICENSE_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your INGEST - LICENSE key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEW_RELIC_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;collector.eu01.nr-data.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;newrelic.ini&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;otlp_host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;otlp.eu01.nr-data.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install CLI flag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEW_RELIC_REGION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EU&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Miss &lt;code&gt;NEW_RELIC_HOST&lt;/code&gt; on the Python agent and you'll spend an hour on a key rejection that is a region mismatch.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>flask</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Gemini called it a public API. Careerjet's registration portal disagreed.</title>
      <dc:creator>Abdulswamad Rama </dc:creator>
      <pubDate>Mon, 01 Jun 2026 03:02:14 +0000</pubDate>
      <link>https://dev.to/rsvlim/gemini-called-it-a-public-api-careerjets-registration-portal-disagreed-2aaf</link>
      <guid>https://dev.to/rsvlim/gemini-called-it-a-public-api-careerjets-registration-portal-disagreed-2aaf</guid>
      <description>&lt;p&gt;I was building something that needed job listings. Gemini flagged Careerjet as a solid option:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Careerjet has a public API — structured data, no scraping, Kenya support."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four requirements it didn't mention.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built first
&lt;/h2&gt;

&lt;p&gt;The old Careerjet affiliate endpoint (&lt;code&gt;public.api.careerjet.net/search&lt;/code&gt;) responds to unauthenticated requests. You pass an &lt;code&gt;affid&lt;/code&gt; parameter — originally used so Careerjet could track clicks and split revenue with partner sites. I passed a placeholder and moved on.&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keywords&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;software engineer&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;location&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;Kenya&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;locale_code&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;en_KE&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;affid&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;your-affid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# placeholder — I planned to "sort this out later"
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pagesize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;url&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://public.api.careerjet.net/search?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;params&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;It returned JSON. I wrote a parser around it. I shipped the file.&lt;/p&gt;

&lt;p&gt;This would have worked right up until it didn't — either Careerjet deprecates the old endpoint, tightens auth requirements, or (more likely) nothing gets properly attributed and whatever publisher agreement exists is quietly violated. But the response came back clean, so I moved on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I actually registered
&lt;/h2&gt;

&lt;p&gt;Out of curiosity, I went to Careerjet's publisher portal. What I found:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An API key tied to my registered domain&lt;/li&gt;
&lt;li&gt;A mandatory IP whitelist — I had to declare which server IPs were allowed to call the API before anything worked&lt;/li&gt;
&lt;li&gt;HTTP Basic auth on every request: API key as the username, empty string as the password&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The real endpoint is &lt;code&gt;https://search.api.careerjet.net/v4/query&lt;/code&gt;. Note the &lt;code&gt;v4&lt;/code&gt;, the &lt;code&gt;https&lt;/code&gt;, the different subdomain, and the complete absence of an &lt;code&gt;affid&lt;/code&gt; param.&lt;/p&gt;

&lt;p&gt;"Public" in Careerjet's vocabulary means &lt;em&gt;available to any publisher who registers, without an approval process&lt;/em&gt;. Not &lt;em&gt;unauthenticated&lt;/em&gt;. The AI had collapsed two different meanings into one confident sentence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The corrected implementation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlencode&lt;/span&gt;

&lt;span class="n"&gt;API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://search.api.careerjet.net/v4/query&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_jobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&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="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locale_code&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;en_KE&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;keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort&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;date&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;page_size&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_ip&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;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36&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="c1"&gt;# Basic auth: base64(api_key + ":")  — password is empty string
&lt;/span&gt;    &lt;span class="n"&gt;credentials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&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;api_key&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="nf"&gt;encode&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;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="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;API_URL&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;params&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&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;Basic &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;credentials&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Referer&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://your-site.com/jobs/&lt;/span&gt;&lt;span class="sh"&gt;"&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;20&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;resp&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;resp&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things the AI's description had entirely omitted:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Basic auth with an empty password.&lt;/strong&gt; The format is &lt;code&gt;base64("your-api-key-here" + ":")&lt;/code&gt;. Username is the key, password is blank. Standard HTTP Basic auth — but you'd only know to look for it in the actual docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;user_ip&lt;/code&gt; and &lt;code&gt;user_agent&lt;/code&gt; are required fields.&lt;/strong&gt; This one is the most interesting. The v4 API was designed for publishers embedding job search in their website — a real user types a query, clicks a button, the API fires. Careerjet wants the user's IP and browser string for analytics.&lt;/p&gt;

&lt;p&gt;My use case: a cron job, no browser, no user, runs at 23:00 UTC. There is no user IP to pass. The spec says "the IP of the user whose action triggered the API call." The nightly cron is the trigger. I pass the server's outbound IP (&lt;code&gt;203.0.113.1&lt;/code&gt; in these examples). The API accepts it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP whitelisting is mandatory before anything works.&lt;/strong&gt; In the publisher dashboard there's a text box — up to 8 IP addresses. Your server's outbound IP must be there or every request returns 403 regardless of correct auth headers. Register it first, then deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Handle the response type
&lt;/h2&gt;

&lt;p&gt;The API returns either &lt;code&gt;"JOBS"&lt;/code&gt; or &lt;code&gt;"LOCATIONS"&lt;/code&gt;. The LOCATIONS case happens when your &lt;code&gt;location&lt;/code&gt; param is ambiguous:&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_jobs&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;data&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JOBS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# LOCATIONS response: Careerjet couldn't resolve the location
&lt;/span&gt;    &lt;span class="c1"&gt;# log data.get("message") and bail
&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;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;jobs&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"Kenya"&lt;/code&gt; and &lt;code&gt;"Nairobi"&lt;/code&gt; both resolve cleanly. Vaguer strings may not.&lt;/p&gt;

&lt;p&gt;Each job object has &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;company&lt;/code&gt;, &lt;code&gt;locations&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; (excerpt), &lt;code&gt;salary&lt;/code&gt;, and &lt;code&gt;url&lt;/code&gt;. The &lt;code&gt;salary&lt;/code&gt; field comes back as a human-readable string like &lt;code&gt;"KES 80,000 – 120,000 per month"&lt;/code&gt; — more useful than trying to parse salary out of a scraped description.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the AI got wrong
&lt;/h2&gt;

&lt;p&gt;It wasn't lying. The old affiliate endpoint at &lt;code&gt;public.api.careerjet.net&lt;/code&gt; is real, responds to requests, and technically works. The description "public API, no scraping" isn't false — it's just about a different version of the API than the one publishers are supposed to use.&lt;/p&gt;

&lt;p&gt;The failure mode is: the AI described behavior that was accurate at some point, for some use case, and I didn't verify which use case it was describing. "Public API" is a phrase that means something specific to the developer who wrote the docs and something looser to a model that's synthesizing from across the web.&lt;/p&gt;

&lt;p&gt;The fix took twenty minutes — register, whitelist the IP, swap the endpoint, add the auth header. The registration is instant, no approval needed. The documentation is clear.&lt;/p&gt;

&lt;p&gt;The cost of not checking: an integration that works in development, silently misattributes traffic, and breaks when the old endpoint eventually goes away.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
