<?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: Pallab Mondal</title>
    <description>The latest articles on DEV Community by Pallab Mondal (@raisereturn).</description>
    <link>https://dev.to/raisereturn</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%2F3954776%2F1608fe98-270c-4725-89e0-a276c93888f4.png</url>
      <title>DEV Community: Pallab Mondal</title>
      <link>https://dev.to/raisereturn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/raisereturn"/>
    <language>en</language>
    <item>
      <title>Stop Building SEO Reports by Hand: Automating Client Reporting With APIs, SQL, and Scheduled Jobs</title>
      <dc:creator>Pallab Mondal</dc:creator>
      <pubDate>Tue, 08 Sep 2026 23:23:16 +0000</pubDate>
      <link>https://dev.to/raisereturn/stop-building-seo-reports-by-hand-automating-client-reporting-with-apis-sql-and-scheduled-jobs-7g7</link>
      <guid>https://dev.to/raisereturn/stop-building-seo-reports-by-hand-automating-client-reporting-with-apis-sql-and-scheduled-jobs-7g7</guid>
      <description>&lt;p&gt;If you manage SEO for more than a couple of websites, you eventually hit the same wall.&lt;/p&gt;

&lt;p&gt;The SEO work itself isn't always the slow part.&lt;/p&gt;

&lt;p&gt;The reporting is.&lt;/p&gt;

&lt;p&gt;Every month, you open Google Search Console. Then Google Analytics. Maybe PageSpeed Insights. You export CSV files, adjust date ranges, copy numbers into a spreadsheet, update charts, write a summary, check that nothing is obviously wrong, and finally turn everything into a PDF.&lt;/p&gt;

&lt;p&gt;Then you do it again for the next client.&lt;/p&gt;

&lt;p&gt;And the next one.&lt;/p&gt;

&lt;p&gt;At first, this feels manageable. After all, you only need an hour or two per report.&lt;/p&gt;

&lt;p&gt;But multiply that by 10, 20, or 50 clients and the problem becomes obvious.&lt;/p&gt;

&lt;p&gt;You're not really doing SEO reporting anymore.&lt;/p&gt;

&lt;p&gt;You're maintaining a manual data pipeline.&lt;/p&gt;

&lt;p&gt;The good news is that most of this process can be automated with fairly ordinary tools: APIs, Python, SQL, scheduled jobs, and a reporting template.&lt;/p&gt;

&lt;p&gt;You don't need a massive data platform.&lt;/p&gt;

&lt;p&gt;You need a reliable pipeline.&lt;/p&gt;

&lt;p&gt;This article walks through how I'd approach building one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem Isn't the Report
&lt;/h2&gt;

&lt;p&gt;A typical SEO report contains several different types of information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organic clicks&lt;/li&gt;
&lt;li&gt;Impressions&lt;/li&gt;
&lt;li&gt;Click-through rate&lt;/li&gt;
&lt;li&gt;Average search position&lt;/li&gt;
&lt;li&gt;Top queries&lt;/li&gt;
&lt;li&gt;Top landing pages&lt;/li&gt;
&lt;li&gt;Organic sessions&lt;/li&gt;
&lt;li&gt;Conversions&lt;/li&gt;
&lt;li&gt;Technical issues&lt;/li&gt;
&lt;li&gt;Period-over-period changes&lt;/li&gt;
&lt;li&gt;Recommendations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difficult part isn't displaying those numbers.&lt;/p&gt;

&lt;p&gt;The difficult part is getting the right data, consistently, for the right date range, then turning it into something a client can understand.&lt;/p&gt;

&lt;p&gt;A manual process might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google Search Console
 ↓
CSV export
 ↓
Spreadsheet
 ↓
Manual calculations
 ↓
Charts
 ↓
Written summary
 ↓
PDF
 ↓
Client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An automated process can look more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 ┌──────────────────┐
                 │ Google Search     │
                 │ Console API       │
                 └────────┬─────────┘
                          │
                          ▼
┌──────────────┐   ┌───────────────┐
│ Google       │──▶│ Data          │
│ Analytics API│   │ Collection    │
└──────────────┘   └───────┬───────┘
                           │
                           ▼
                    ┌─────────────┐
                    │ PostgreSQL  │
                    └──────┬──────┘
                           │
                           ▼
                    ┌─────────────┐
                    │ Calculations│
                    └──────┬──────┘
                           │
                           ▼
                    ┌─────────────┐
                    │ Report      │
                    │ Generator   │
                    └──────┬──────┘
                           │
                           ▼
                     PDF / HTML
                           │
                           ▼
                        Client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction is important.&lt;/p&gt;

&lt;p&gt;You're not automating a PDF.&lt;/p&gt;

&lt;p&gt;You're automating the system that produces the PDF.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decide What the Report Should Answer
&lt;/h2&gt;

&lt;p&gt;Before touching an API, decide what questions your report should answer.&lt;/p&gt;

&lt;p&gt;Clients rarely care about a number just because it exists.&lt;/p&gt;

&lt;p&gt;They care about questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did organic traffic grow?&lt;/li&gt;
&lt;li&gt;Did search visibility improve?&lt;/li&gt;
&lt;li&gt;Which pages generated the most traffic?&lt;/li&gt;
&lt;li&gt;Which pages lost traffic?&lt;/li&gt;
&lt;li&gt;Are rankings moving in the right direction?&lt;/li&gt;
&lt;li&gt;Did organic visitors generate leads or sales?&lt;/li&gt;
&lt;li&gt;What changed compared with last month?&lt;/li&gt;
&lt;li&gt;What should we work on next?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical overview of &lt;a href="https://raisereturn.com/what-is-an-seo-report/" rel="noopener noreferrer"&gt;what belongs in an SEO report&lt;/a&gt; is also worth keeping nearby when designing the template.&lt;/p&gt;

&lt;p&gt;The answers determine what data you actually need.&lt;/p&gt;

&lt;p&gt;For example, if your goal is to report on organic growth, Search Console data might be enough for search performance.&lt;/p&gt;

&lt;p&gt;If you want to connect SEO to business results, you'll probably also need Google Analytics data.&lt;/p&gt;

&lt;p&gt;If you're reporting on technical SEO, you'll need another data source entirely.&lt;/p&gt;

&lt;p&gt;The first lesson is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don't collect data just because an API makes it available.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Collect data because it answers a useful question.&lt;/p&gt;




&lt;h1&gt;
  
  
  Start With Google Search Console
&lt;/h1&gt;

&lt;p&gt;Google Search Console is usually the best place to start.&lt;/p&gt;

&lt;p&gt;The Search Console API gives you access to performance information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clicks&lt;/li&gt;
&lt;li&gt;Impressions&lt;/li&gt;
&lt;li&gt;CTR&lt;/li&gt;
&lt;li&gt;Average position&lt;/li&gt;
&lt;li&gt;Queries&lt;/li&gt;
&lt;li&gt;Pages&lt;/li&gt;
&lt;li&gt;Countries&lt;/li&gt;
&lt;li&gt;Devices&lt;/li&gt;
&lt;li&gt;Search appearance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can request a date range and optionally group the results by dimensions such as query or page.&lt;/p&gt;

&lt;p&gt;Conceptually, a response might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"keys"&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;"seo automation"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"clicks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;124&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"impressions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ctr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0516&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.4&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"keys"&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;"automated seo reporting"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"clicks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;97&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"impressions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ctr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0538&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"position"&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.2&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can then transform that response into your own internal format.&lt;/p&gt;

&lt;p&gt;For example:&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;keyword&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="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keys&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;clicks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clicks&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;impressions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;impressions&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;ctr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ctr&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;position&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position&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;Once the data is inside your application, you shouldn't care too much about where it originally came from.&lt;/p&gt;

&lt;p&gt;That's an important architectural decision.&lt;/p&gt;

&lt;p&gt;Your report generator shouldn't need to know whether a metric came from Search Console, Analytics, a CSV file, or a database.&lt;/p&gt;




&lt;h1&gt;
  
  
  Compare Periods Instead of Showing Snapshots
&lt;/h1&gt;

&lt;p&gt;A report saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Organic clicks: 14,320&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;isn't particularly useful.&lt;/p&gt;

&lt;p&gt;Is that good?&lt;/p&gt;

&lt;p&gt;Was it 12,000 last month?&lt;/p&gt;

&lt;p&gt;Was it 20,000?&lt;/p&gt;

&lt;p&gt;Without context, the number means very little.&lt;/p&gt;

&lt;p&gt;Instead, calculate changes.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;percent_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&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;previous&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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&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;current_clicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14320&lt;/span&gt;
&lt;span class="n"&gt;previous_clicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12000&lt;/span&gt;

&lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;percent_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_clicks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_clicks&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;change&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;%&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;The result is:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Now your report can say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Organic clicks increased 19.3% compared with the previous period.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's much more useful than displaying two disconnected numbers.&lt;/p&gt;

&lt;p&gt;You can use the same approach for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Impressions&lt;/li&gt;
&lt;li&gt;Sessions&lt;/li&gt;
&lt;li&gt;Conversions&lt;/li&gt;
&lt;li&gt;Revenue&lt;/li&gt;
&lt;li&gt;Landing-page traffic&lt;/li&gt;
&lt;li&gt;Keyword groups&lt;/li&gt;
&lt;li&gt;Technical issues&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison itself often contains more information than the raw metric.&lt;/p&gt;




&lt;h1&gt;
  
  
  Then Bring in GA4
&lt;/h1&gt;

&lt;p&gt;Search Console tells you what happened in Google Search.&lt;/p&gt;

&lt;p&gt;Google Analytics can help answer what happened after someone arrived.&lt;/p&gt;

&lt;p&gt;Depending on the reporting goal, you might collect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organic sessions&lt;/li&gt;
&lt;li&gt;Engaged sessions&lt;/li&gt;
&lt;li&gt;Users&lt;/li&gt;
&lt;li&gt;Conversions&lt;/li&gt;
&lt;li&gt;Revenue&lt;/li&gt;
&lt;li&gt;Landing pages&lt;/li&gt;
&lt;li&gt;Engagement metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple landing-page dataset might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/seo-guide"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sessions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1820&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/technical-seo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sessions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/keyword-research"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sessions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;980&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can combine this with Search Console information.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search Console
    ↓
Which pages receive search visibility?

GA4
    ↓
Which pages receive organic visitors?

Conversions
    ↓
Which pages contribute to business outcomes?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a much stronger report.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This page ranks well.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can potentially say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This page is receiving significant organic visibility and is also contributing to conversions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the difference between an SEO report and a collection of SEO metrics.&lt;/p&gt;




&lt;h1&gt;
  
  
  Authentication Is Where the Fun Begins
&lt;/h1&gt;

&lt;p&gt;APIs are convenient once authentication works.&lt;/p&gt;

&lt;p&gt;Getting authentication configured is often the annoying part.&lt;/p&gt;

&lt;p&gt;For Google APIs, you'll typically need to deal with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud project&lt;/li&gt;
&lt;li&gt;API access&lt;/li&gt;
&lt;li&gt;Credentials&lt;/li&gt;
&lt;li&gt;OAuth or service-account authentication&lt;/li&gt;
&lt;li&gt;Appropriate permissions&lt;/li&gt;
&lt;li&gt;Property/account access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a single internal project, you can keep this fairly simple.&lt;/p&gt;

&lt;p&gt;For an agency platform, things become more interesting.&lt;/p&gt;

&lt;p&gt;You may have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client A
  ├── Search Console property
  ├── GA4 property
  └── Credentials

Client B
  ├── Search Console property
  ├── GA4 property
  └── Credentials

Client C
  ├── Search Console property
  ├── GA4 property
  └── Credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your database might contain something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;clients&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;search_console_property&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ga4_property_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Credentials should &lt;strong&gt;not&lt;/strong&gt; simply be stored as plaintext database fields.&lt;/p&gt;

&lt;p&gt;Use environment variables, a secret manager, or encrypted credential storage appropriate for your deployment.&lt;/p&gt;

&lt;p&gt;For example:&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;GOOGLE_APPLICATION_CREDENTIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/secrets/google-service-account.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact authentication architecture will depend on whether you're building a personal tool, an agency platform, or a SaaS product.&lt;/p&gt;




&lt;h1&gt;
  
  
  Don't Generate the PDF Directly From the API
&lt;/h1&gt;

&lt;p&gt;This is one of the architectural mistakes I'd avoid.&lt;/p&gt;

&lt;p&gt;You don't want code that effectively does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API request
   ↓
PDF generation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works initially.&lt;/p&gt;

&lt;p&gt;Then the report becomes difficult to maintain.&lt;/p&gt;

&lt;p&gt;Instead, create layers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API
 ↓
Raw data
 ↓
Normalized data
 ↓
Metrics
 ↓
Report model
 ↓
Template
 ↓
PDF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, your normalized object could look like:&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;report_data&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;period&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;current&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;2026-08-01/2026-08-31&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;previous&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;2026-07-01/2026-07-31&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;organic_search&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;clicks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14320&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;impressions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;248000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ctr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0577&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;9.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;analytics&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;organic_sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;17640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conversions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;412&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;Now your PDF generator doesn't need to understand Google APIs.&lt;/p&gt;

&lt;p&gt;It just receives structured data.&lt;/p&gt;

&lt;p&gt;That makes testing much easier.&lt;/p&gt;

&lt;p&gt;You can even generate a report from a fake dataset without making a single external API call.&lt;/p&gt;




&lt;h1&gt;
  
  
  Store Historical Data
&lt;/h1&gt;

&lt;p&gt;If you're serious about automation, don't rely entirely on live API calls.&lt;/p&gt;

&lt;p&gt;Store historical data.&lt;/p&gt;

&lt;p&gt;A simple PostgreSQL table might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;seo_daily&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;clicks&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;impressions&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;ctr&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;position&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;organic_sessions&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;conversions&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your reporting system can query historical data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clicks&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;clicks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;impressions&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;impressions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organic_sessions&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;sessions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversions&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;conversions&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;seo_daily&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you several advantages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Historical comparisons
&lt;/h3&gt;

&lt;p&gt;You can compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Month over month&lt;/li&gt;
&lt;li&gt;Quarter over quarter&lt;/li&gt;
&lt;li&gt;Year over year&lt;/li&gt;
&lt;li&gt;Custom periods&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Faster reports
&lt;/h3&gt;

&lt;p&gt;Your report generator doesn't have to request everything from external APIs every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging
&lt;/h3&gt;

&lt;p&gt;If an API returns strange data today, you can inspect exactly what was stored previously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducibility
&lt;/h3&gt;

&lt;p&gt;You can regenerate an old report using the data that existed when the report was originally produced.&lt;/p&gt;

&lt;p&gt;That's valuable when clients ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can you resend the report from six months ago?"&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Keep Raw and Processed Data Separate
&lt;/h1&gt;

&lt;p&gt;A useful pattern is to distinguish between raw data and processed data.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw API response
      ↓
Normalization
      ↓
Business calculations
      ↓
Report data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raw data might look messy.&lt;/p&gt;

&lt;p&gt;That's okay.&lt;/p&gt;

&lt;p&gt;The normalized layer should be predictable.&lt;/p&gt;

&lt;p&gt;For example:&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="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;2026-08-31&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;source&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;search_console&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;metric&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;clicks&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="mi"&gt;14320&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your calculation layer can work with consistent structures.&lt;/p&gt;

&lt;p&gt;This also means you can change your report design without changing your API integration.&lt;/p&gt;

&lt;p&gt;That's exactly what you want.&lt;/p&gt;




&lt;h1&gt;
  
  
  Build the Report Around Decisions
&lt;/h1&gt;

&lt;p&gt;A common reporting mistake is starting with charts.&lt;/p&gt;

&lt;p&gt;Instead, start with decisions.&lt;/p&gt;

&lt;p&gt;Imagine these two sections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version A
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Organic clicks: 14,320
Impressions: 248,000
CTR: 5.77%
Average position: 9.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Version B
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Organic clicks increased 19% compared with the previous period.

The strongest growth came from informational pages,
while two commercial landing pages lost visibility.

The next priority is improving internal linking to the
declining commercial pages and refreshing their content.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Version B is much more useful.&lt;/p&gt;

&lt;p&gt;The numbers can still be included.&lt;/p&gt;

&lt;p&gt;But they support the explanation.&lt;/p&gt;

&lt;p&gt;A good report should answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;So what?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the question that turns analytics into strategy.&lt;/p&gt;




&lt;h1&gt;
  
  
  Add Simple Anomaly Detection
&lt;/h1&gt;

&lt;p&gt;You don't need machine learning to make automated reporting smarter.&lt;/p&gt;

&lt;p&gt;Start with rules.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&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;if&lt;/span&gt; &lt;span class="n"&gt;previous&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="n"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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;change&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;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;change&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;significant&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;change&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;significant&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you can flag situations like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clicks ↓ 28%
Conversions ↓ 34%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Impressions ↑ 41%
Clicks ↑ 3%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second example is interesting.&lt;/p&gt;

&lt;p&gt;Visibility increased substantially, but clicks barely moved.&lt;/p&gt;

&lt;p&gt;That could justify investigating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search intent&lt;/li&gt;
&lt;li&gt;SERP features&lt;/li&gt;
&lt;li&gt;Titles&lt;/li&gt;
&lt;li&gt;Meta descriptions&lt;/li&gt;
&lt;li&gt;Ranking distribution&lt;/li&gt;
&lt;li&gt;Query mix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system doesn't need to diagnose everything automatically.&lt;/p&gt;

&lt;p&gt;It just needs to surface things worth investigating.&lt;/p&gt;




&lt;h1&gt;
  
  
  Schedule the Whole Pipeline
&lt;/h1&gt;

&lt;p&gt;Once your pipeline works manually, schedule it.&lt;/p&gt;

&lt;p&gt;A simple architecture might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Every Monday at 06:00
        ↓
Fetch API data
        ↓
Validate response
        ↓
Store raw data
        ↓
Normalize data
        ↓
Calculate metrics
        ↓
Detect anomalies
        ↓
Generate report
        ↓
Upload PDF
        ↓
Send email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a small deployment, cron may be enough.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; 1 /usr/bin/python3 /app/run_reports.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For larger systems, you might eventually use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Celery&lt;/li&gt;
&lt;li&gt;Cloud Scheduler&lt;/li&gt;
&lt;li&gt;GitHub Actions&lt;/li&gt;
&lt;li&gt;AWS EventBridge&lt;/li&gt;
&lt;li&gt;Google Cloud Scheduler&lt;/li&gt;
&lt;li&gt;Kubernetes CronJobs&lt;/li&gt;
&lt;li&gt;A managed workflow system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't start with the most complicated scheduler you can find.&lt;/p&gt;

&lt;p&gt;Start with the simplest reliable option.&lt;/p&gt;




&lt;h1&gt;
  
  
  Make Failures Boring
&lt;/h1&gt;

&lt;p&gt;Automated systems fail.&lt;/p&gt;

&lt;p&gt;APIs timeout.&lt;/p&gt;

&lt;p&gt;Credentials expire.&lt;/p&gt;

&lt;p&gt;Rate limits happen.&lt;/p&gt;

&lt;p&gt;A property gets removed.&lt;/p&gt;

&lt;p&gt;A database connection fails.&lt;/p&gt;

&lt;p&gt;The goal isn't to eliminate every failure.&lt;/p&gt;

&lt;p&gt;The goal is to make failures easy to understand.&lt;/p&gt;

&lt;p&gt;For example:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_search_console_data&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;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&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 Console request failed&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;You should ideally track:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;START
  ↓
FETCH
  ↓
VALIDATE
  ↓
STORE
  ↓
CALCULATE
  ↓
GENERATE
  ↓
SEND
  ↓
SUCCESS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something breaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;START
  ↓
FETCH
  ↓
ERROR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You immediately know where to look.&lt;/p&gt;

&lt;p&gt;Good logging is one of those boring engineering practices that becomes incredibly useful once you have dozens of automated jobs running.&lt;/p&gt;




&lt;h1&gt;
  
  
  Add Retries Carefully
&lt;/h1&gt;

&lt;p&gt;Temporary API failures shouldn't necessarily kill the entire report.&lt;/p&gt;

&lt;p&gt;A simple retry mechanism can help:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&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;delay&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&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="n"&gt;attempts&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;return&lt;/span&gt; &lt;span class="nf"&gt;func&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;Exception&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;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;attempts&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="k"&gt;raise&lt;/span&gt;

            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But don't blindly retry every error.&lt;/p&gt;

&lt;p&gt;A retry makes sense for things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Temporary network errors&lt;/li&gt;
&lt;li&gt;Timeouts&lt;/li&gt;
&lt;li&gt;Some server-side failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It doesn't fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invalid credentials&lt;/li&gt;
&lt;li&gt;Invalid property IDs&lt;/li&gt;
&lt;li&gt;Permission problems&lt;/li&gt;
&lt;li&gt;Malformed requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good retry logic should distinguish temporary problems from permanent ones.&lt;/p&gt;




&lt;h1&gt;
  
  
  Generate Different Report Formats From the Same Data
&lt;/h1&gt;

&lt;p&gt;Once you have a clean report model, you can generate multiple outputs.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Normalized report data
        │
        ├── PDF
        ├── HTML
        ├── Email
        ├── Dashboard
        ├── JSON
        └── Spreadsheet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is another reason not to couple your calculations directly to the PDF.&lt;/p&gt;

&lt;p&gt;Your report model might be:&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;report&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;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organic_search&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;landing_pages&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;conversions&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;technical&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;recommendations&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PDF template consumes it.&lt;/p&gt;

&lt;p&gt;An email template consumes it.&lt;/p&gt;

&lt;p&gt;A dashboard consumes it.&lt;/p&gt;

&lt;p&gt;A future API endpoint consumes it.&lt;/p&gt;

&lt;p&gt;One data model.&lt;/p&gt;

&lt;p&gt;Multiple outputs.&lt;/p&gt;




&lt;h1&gt;
  
  
  A Practical Report Structure
&lt;/h1&gt;

&lt;p&gt;If I were building an automated client report today, I'd probably start with something like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Executive Summary
&lt;/h2&gt;

&lt;p&gt;Three to five important observations.&lt;/p&gt;

&lt;p&gt;Not 20 metrics.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Organic traffic increased 18% this month.

Search visibility also improved, with impressions increasing 31%.

However, conversions grew only 4%, suggesting that traffic quality
should be investigated before scaling content production.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. Organic Search Performance
&lt;/h2&gt;

&lt;p&gt;Include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clicks&lt;/li&gt;
&lt;li&gt;Impressions&lt;/li&gt;
&lt;li&gt;CTR&lt;/li&gt;
&lt;li&gt;Average position&lt;/li&gt;
&lt;li&gt;Period comparison&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A line chart can show the trend.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Top Landing Pages
&lt;/h2&gt;

&lt;p&gt;Show:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;th&gt;Sessions&lt;/th&gt;
&lt;th&gt;Conversions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/seo-guide&lt;/td&gt;
&lt;td&gt;3,420&lt;/td&gt;
&lt;td&gt;4,120&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/technical-seo&lt;/td&gt;
&lt;td&gt;2,870&lt;/td&gt;
&lt;td&gt;3,180&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/keyword-research&lt;/td&gt;
&lt;td&gt;2,140&lt;/td&gt;
&lt;td&gt;2,630&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;But don't stop there.&lt;/p&gt;

&lt;p&gt;Highlight unusual changes.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

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

Traffic: +38%
Conversions: +52%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's much more useful than simply ranking pages from highest to lowest.&lt;/p&gt;




&lt;h1&gt;
  
  
  Don't Automate the Human Explanation Too Aggressively
&lt;/h1&gt;

&lt;p&gt;Automation can easily produce terrible writing.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Organic clicks increased by 19.3%, indicating a positive performance trend and suggesting that SEO visibility has improved during the reporting period.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Technically correct.&lt;/p&gt;

&lt;p&gt;Painfully robotic.&lt;/p&gt;

&lt;p&gt;A better approach is to use structured templates and leave room for human judgment.&lt;/p&gt;

&lt;p&gt;For example:&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;if&lt;/span&gt; &lt;span class="n"&gt;clicks_change&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;conversions_change&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;summary&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;Organic search improved strongly this period, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with both traffic and conversions moving higher.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If traffic rises but conversions fall:&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;if&lt;/span&gt; &lt;span class="n"&gt;clicks_change&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;conversions_change&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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="n"&gt;summary&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 traffic increased, but conversions declined. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The next step is to investigate whether the additional &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traffic is reaching the right landing pages.&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;These aren't meant to replace an SEO professional.&lt;/p&gt;

&lt;p&gt;They're meant to handle the repetitive 80%.&lt;/p&gt;

&lt;p&gt;The human should still be able to edit the final interpretation.&lt;/p&gt;




&lt;h1&gt;
  
  
  Where AI Actually Helps
&lt;/h1&gt;

&lt;p&gt;AI can be useful here, but I'd give it a narrow job.&lt;/p&gt;

&lt;p&gt;Don't send raw API responses to an AI model and ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Write my SEO report."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's an easy way to get invented conclusions.&lt;/p&gt;

&lt;p&gt;Instead, calculate the facts yourself.&lt;/p&gt;

&lt;p&gt;Then provide structured information.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"organic_clicks_change"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;19.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;"organic_sessions_change"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;17.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conversions_change"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.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;"top_growth_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/seo-guide"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"declining_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/technical-seo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"technical_issues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&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;Then ask the model to interpret those facts.&lt;/p&gt;

&lt;p&gt;You can constrain it further:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use only the supplied metrics.

Do not invent causes.

Do not invent rankings.

Do not claim that a change was caused by an algorithm update
unless that information is explicitly provided.

Return:
1. Three observations
2. Two risks
3. Three recommended next actions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a much safer use of AI.&lt;/p&gt;

&lt;p&gt;The application owns the facts.&lt;/p&gt;

&lt;p&gt;The model helps turn structured facts into readable language.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Architecture I'd Build
&lt;/h1&gt;

&lt;p&gt;For a small SEO agency, I'd keep the first version relatively boring.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  ┌──────────────────┐
                  │ Search Console   │
                  └────────┬─────────┘
                           │
                           ▼
                  ┌──────────────────┐
                  │ Data Collector   │
                  └────────┬─────────┘
                           │
                  ┌────────┴─────────┐
                  │                  │
                  ▼                  ▼
          ┌──────────────┐   ┌──────────────┐
          │ Raw Storage  │   │ GA4 Collector│
          └──────┬───────┘   └──────┬───────┘
                 │                  │
                 └────────┬─────────┘
                          ▼
                  ┌──────────────┐
                  │ PostgreSQL   │
                  └──────┬───────┘
                         │
                         ▼
                  ┌──────────────┐
                  │ Calculations │
                  └──────┬───────┘
                         │
                         ▼
                  ┌──────────────┐
                  │ Report Model │
                  └──────┬───────┘
                         │
                ┌────────┴─────────┐
                ▼                  ▼
          ┌───────────┐      ┌───────────┐
          │ PDF       │      │ HTML      │
          └───────────┘      └───────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stack could be surprisingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python
PostgreSQL
Google APIs
Jinja2
HTML/CSS
PDF renderer
Cron
Object storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't need Kubernetes on day one.&lt;/p&gt;

&lt;p&gt;You don't need a distributed event system.&lt;/p&gt;

&lt;p&gt;You don't need 17 microservices.&lt;/p&gt;

&lt;p&gt;You need a reliable pipeline.&lt;/p&gt;




&lt;h1&gt;
  
  
  What I Would Automate First
&lt;/h1&gt;

&lt;p&gt;If you're starting from zero, I'd build it in this order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Automate data collection
&lt;/h3&gt;

&lt;p&gt;Get Search Console data without manual exports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Store the data
&lt;/h3&gt;

&lt;p&gt;Put it somewhere persistent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add GA4
&lt;/h3&gt;

&lt;p&gt;Connect search performance with website behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Calculate comparisons
&lt;/h3&gt;

&lt;p&gt;Month over month is enough initially.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Add anomaly detection
&lt;/h3&gt;

&lt;p&gt;Flag large changes automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Create a report model
&lt;/h3&gt;

&lt;p&gt;Separate data from presentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Build one good template
&lt;/h3&gt;

&lt;p&gt;Don't create five formats immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Schedule it
&lt;/h3&gt;

&lt;p&gt;Make the pipeline run without manual intervention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 9: Add AI carefully
&lt;/h3&gt;

&lt;p&gt;Use it for interpretation, not fact generation.&lt;/p&gt;

&lt;p&gt;This order matters.&lt;/p&gt;

&lt;p&gt;If the underlying data isn't reliable, a beautifully designed AI-generated report doesn't solve anything.&lt;/p&gt;

&lt;p&gt;It just produces unreliable information faster.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Biggest Reporting Mistake
&lt;/h1&gt;

&lt;p&gt;The easiest metrics to automate aren't necessarily the most valuable ones.&lt;/p&gt;

&lt;p&gt;It's very easy to create a report containing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Keywords: 4,281
Backlinks: 1,204
Domain Rating: 62
Pages Crawled: 8,291
Impressions: 248,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks impressive.&lt;/p&gt;

&lt;p&gt;But what did the business actually gain?&lt;/p&gt;

&lt;p&gt;A better report might contain fewer numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Organic traffic: +18%

Organic conversions: +14%

Revenue from organic traffic: +21%

Three commercial pages lost visibility.

Next priority:
refresh those pages and strengthen internal links.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a business conversation.&lt;/p&gt;

&lt;p&gt;The purpose of SEO reporting isn't to prove that you've collected a lot of data.&lt;/p&gt;

&lt;p&gt;It's to explain what happened and what should happen next.&lt;/p&gt;




&lt;h1&gt;
  
  
  A Useful Rule for Automated SEO Reporting
&lt;/h1&gt;

&lt;p&gt;Here's the rule I'd keep on the wall:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Automate collection. Automate calculation. Automate repetition. Keep judgment human.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;APIs are excellent at collecting information.&lt;/p&gt;

&lt;p&gt;SQL is excellent at storing and aggregating it.&lt;/p&gt;

&lt;p&gt;Python is excellent at transforming it.&lt;/p&gt;

&lt;p&gt;Schedulers are excellent at repeating it.&lt;/p&gt;

&lt;p&gt;Templates are excellent at formatting it.&lt;/p&gt;

&lt;p&gt;But deciding what matters?&lt;/p&gt;

&lt;p&gt;That's where human expertise still earns its keep.&lt;/p&gt;




&lt;h1&gt;
  
  
  From Spreadsheet Chore to Reporting System
&lt;/h1&gt;

&lt;p&gt;The first time you automate an SEO report, the result might not look revolutionary.&lt;/p&gt;

&lt;p&gt;Maybe you save 30 minutes.&lt;/p&gt;

&lt;p&gt;That's fine.&lt;/p&gt;

&lt;p&gt;Then you automate another client.&lt;/p&gt;

&lt;p&gt;Then another.&lt;/p&gt;

&lt;p&gt;Soon the same system is collecting data, calculating changes, generating charts, producing reports, and preparing summaries for dozens of properties.&lt;/p&gt;

&lt;p&gt;That's when the economics change.&lt;/p&gt;

&lt;p&gt;Instead of spending Monday morning copying metrics into spreadsheets, you can spend that time looking at the exceptions.&lt;/p&gt;

&lt;p&gt;Which pages are growing?&lt;/p&gt;

&lt;p&gt;Which pages are declining?&lt;/p&gt;

&lt;p&gt;Where are conversions moving?&lt;/p&gt;

&lt;p&gt;What changed?&lt;/p&gt;

&lt;p&gt;What should we test next?&lt;/p&gt;

&lt;p&gt;That's where SEO work becomes more interesting.&lt;/p&gt;

&lt;p&gt;The goal isn't to eliminate reporting.&lt;/p&gt;

&lt;p&gt;The goal is to eliminate the repetitive parts that don't require your attention.&lt;/p&gt;

&lt;p&gt;Build the pipeline once.&lt;/p&gt;

&lt;p&gt;Let the machine collect the numbers.&lt;/p&gt;

&lt;p&gt;Then spend your time explaining what they mean.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>api</category>
      <category>automation</category>
      <category>seo</category>
    </item>
    <item>
      <title>I Spent 3 Weeks Debugging Rate Limits Before I Realized the Problem Wasn't My Code</title>
      <dc:creator>Pallab Mondal</dc:creator>
      <pubDate>Thu, 23 Jul 2026 21:22:36 +0000</pubDate>
      <link>https://dev.to/raisereturn/i-spent-3-weeks-debugging-rate-limits-before-i-realized-the-problem-wasnt-my-code-51i1</link>
      <guid>https://dev.to/raisereturn/i-spent-3-weeks-debugging-rate-limits-before-i-realized-the-problem-wasnt-my-code-51i1</guid>
      <description>&lt;p&gt;Ever chased a bug for days, only to discover the "bug" was actually the platform working exactly as designed? That happened to me building a client reporting pipeline. The lesson stuck.&lt;/p&gt;

&lt;p&gt;Here's what nobody tells you about pulling marketing data from multiple ad platforms: the hard part was never the dashboard. It was everything underneath it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Setup That Looked Simple on Paper&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The brief sounded easy. Pull spend, clicks, and conversions from Google Ads and Meta. Store it. Display it in a chart. A junior dev could knock this out in a sprint, I figured.&lt;/p&gt;

&lt;p&gt;Reality disagreed. Google Ads API enforces operation quotas per developer token, and those quotas scale differently depending on account tier. Meanwhile, Meta's Marketing API throttles based on a rolling usage score tied to the ad account itself, not your app.&lt;/p&gt;

&lt;p&gt;Two platforms. Two completely different throttling philosophies. Neither documented in a way that made the actual limits obvious until you hit them in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where Things Actually Broke&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first version polled every client account every hour. Fine for three clients. Then we onboarded client number twelve, and Meta started returning 429s intermittently. Not consistently — intermittently. That's the worst kind of bug.&lt;/p&gt;

&lt;p&gt;I initially assumed it was a code issue. Retry logic, maybe a race condition in my job scheduler. I spent three weeks going down that path. Eventually, I found the real cause: cumulative API call volume across all client accounts was tripping Meta's app-level rate limit, not the individual account limit.&lt;/p&gt;

&lt;p&gt;The fix wasn't more retries. It was a request queue with exponential backoff, plus a priority system so active dashboards refreshed before idle ones. Simple in hindsight. Expensive in dev hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Real Architecture Behind Multi-Platform Reporting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're building this yourself, here's what a production-grade pipeline actually needs, based on what broke for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Queue, Not a Cron Job&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't just fire off API calls on a schedule and hope for the best. Use a proper job queue — something like BullMQ or Sidekiq — with retry policies and backoff built in from day one. This alone would've saved me those three weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token Refresh as a First-Class Concern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OAuth tokens expire silently. Build monitoring specifically for auth failures, separate from your general error logging. Otherwise, you'll find out a client's Google Ads token expired only when they ask why their dashboard looks empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Normalization Layer Between Raw Data and Storage&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google Ads calls it "cost." Meta calls it "spend." GA4 buries similar metrics under different dimension names entirely. Build one internal schema and map every platform into it, rather than letting frontend code handle platform-specific field names.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;javascript&lt;br&gt;
// Simplified normalization example&lt;br&gt;
function normalizeMetric(platform, rawData) {&lt;br&gt;
  const mapping = {&lt;br&gt;
    google_ads: { cost: rawData.cost_micros / 1e6, clicks: rawData.clicks },&lt;br&gt;
    meta: { cost: rawData.spend, clicks: rawData.clicks },&lt;br&gt;
  };&lt;br&gt;
  return mapping[platform] || rawData;&lt;br&gt;
}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This function looks trivial. It isn't, once you're handling currency conversions, timezone mismatches, and attribution window differences across five platforms simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should You Actually Build This?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the honest answer, from someone who's now maintained this kind of system for two years. If reporting infrastructure isn't your product's core differentiator, building it yourself is usually the wrong call.&lt;/p&gt;

&lt;p&gt;I don't say that lightly. I like building things. However, every hour spent debugging Meta's rate limit quirks was an hour not spent on features that actually made our product better for users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Changed My Mind&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Eventually, we moved most of our client reporting workflow onto a dedicated platform, &lt;a href="https://raisereturn.com/" rel="noopener noreferrer"&gt;RaiseReturn&lt;/a&gt;, instead of continuing to maintain our in-house pipeline. It already handled the multi-platform normalization, the token refresh monitoring, and the rate-limit-aware queuing I'd built by hand.&lt;/p&gt;

&lt;p&gt;The API integrations existed already, tested against the platforms' quirks by a team that deals with exactly this problem full-time. That's not something you replicate cheaply in a side project or even a dedicated sprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where Custom Code Still Makes Sense&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To be clear, this isn't an argument against ever building integrations. If your reporting needs pull from truly proprietary internal systems — a custom CRM, an internal data warehouse — no off-the-shelf tool covers that. You'll build custom connectors regardless.&lt;/p&gt;

&lt;p&gt;For standard ad platforms though, the problem is already solved. Well-tested, production-hardened, and maintained by people who get paged when Meta ships a breaking API change at 2 AM. You probably don't want to be that person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practical Takeaways for Your Own Stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're currently building or maintaining a custom reporting pipeline, a few things worth checking right now.&lt;/p&gt;

&lt;p&gt;First, verify your retry logic actually implements exponential backoff, not fixed-interval retries. Fixed intervals make rate limiting worse, not better, under sustained load.&lt;/p&gt;

&lt;p&gt;Second, add dedicated alerting for OAuth token failures. Silent auth failures are the most common cause of "why is this client's data missing" tickets.&lt;/p&gt;

&lt;p&gt;Third, honestly evaluate the maintenance cost against a dedicated automated reporting tool. Sometimes the math favors buying. Sometimes it doesn't. Either way, run the numbers before defaulting to "we'll just build it."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Thought&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Client reporting infrastructure is a genuinely interesting engineering problem. It's also, for most teams, not the problem worth solving from scratch. Know the difference, and your future 2 AM self will thank you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>analytics</category>
      <category>productivity</category>
      <category>seo</category>
    </item>
    <item>
      <title>How We Built an Asynchronous Multi-API Pipeline for Marketing Automation</title>
      <dc:creator>Pallab Mondal</dc:creator>
      <pubDate>Wed, 27 May 2026 20:33:23 +0000</pubDate>
      <link>https://dev.to/raisereturn/how-we-built-an-asynchronous-multi-api-pipeline-for-marketing-automation-16nc</link>
      <guid>https://dev.to/raisereturn/how-we-built-an-asynchronous-multi-api-pipeline-for-marketing-automation-16nc</guid>
      <description>&lt;p&gt;`&lt;/p&gt;
&lt;p&gt;When you are building a platform designed to consolidate data from Google Ads, GA4, Meta Ads, and Google Search Console, you quickly run into a massive bottleneck: &lt;strong&gt;API latency and rate limiting.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If an agency has 50 clients, and each client needs data pulled from 4 different ad networks, a chronological extraction loop will completely stall your application. Your server will time out, your database connection pools will exhaust, and your users will look at a spinning loading wheel forever.&lt;/p&gt;

&lt;p&gt;While the core engine of our platform—&lt;a href="https://raisereturn.com" rel="noopener noreferrer"&gt;RaiseReturn&lt;/a&gt;—remains a proprietary, private codebase, I wanted to share the exact architectural logic we used to solve the asynchronous fetching problem in Node.js.&lt;/p&gt;

&lt;h3&gt;The Bottleneck: Chronological vs. Parallel Processing&lt;/h3&gt;

&lt;p&gt;Most standard API wrappers encourage you to fetch data chronologically:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Wait for Google Ads to respond.&lt;/li&gt;
  &lt;li&gt;Take that data, then wait for Meta Ads to respond.&lt;/li&gt;
  &lt;li&gt;Wait for GA4 to respond.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a terrible approach for multi-tenant scalability. If one API experiences a 3-second network delay, your entire pipeline backs up. Instead, you need to execute these network requests concurrently, handle individual failures gracefully without crashing the whole sequence, and normalize the disparate payloads into a unified schema before writing to your database.&lt;/p&gt;

&lt;h3&gt;A Sanitized Example: The Asynchronous Batch Worker&lt;/h3&gt;

&lt;p&gt;Below is a lightweight, clean abstraction of how you can structure a data worker to fetch metrics from entirely different API endpoints concurrently using &lt;code&gt;Promise.allSettled&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;allSettled&lt;/code&gt; ensures that if the Meta Ads API randomly throws an authentication error or a 500 timeout, your script still successfully captures and processes the Google Ads and GA4 data for that client.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// A lightweight abstraction of a concurrent data extraction worker
const fetchClientMetrics = async (clientConfig) =&amp;gt; {
  const providers = [
    { name: 'googleAds', fetchFn: callGoogleAdsAPI },
    { name: 'metaAds', fetchFn: callMetaAdsAPI },
    { name: 'ga4', fetchFn: callGA4API }
  ];

  // Execute all network requests concurrently
  const fetchPromises = providers.map(provider =&amp;gt; 
    provider.fetchFn(clientConfig[provider.name])
      .then(data =&amp;gt; ({ provider: provider.name, success: true, data }))
      .catch(error =&amp;gt; ({ provider: provider.name, success: false, error: error.message }))
  );

  const results = await Promise.allSettled(fetchPromises);
  
  const payload = {
    clientId: clientConfig.id,
    timestamp: new Date(),
    metrics: {}
  };

  results.forEach((result) =&amp;gt; {
    if (result.status === 'fulfilled') {
      const { provider, success, data, error } = result.value;
      if (success) {
        // Normalize your data pipeline here
        payload.metrics[provider] = data;
      } else {
        console.error(`Error pulling from ${provider}:`, error);
        payload.metrics[provider] = { error: 'Extraction failed' };
      }
    }
  });

  return payload;
};&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Scaling Up to Production&lt;/h3&gt;

&lt;p&gt;In a real production environment, you cannot simply let hundreds of these tasks fire randomly inside an Express controller. Furthermore, you have to back this up with a robust message broker or worker layer:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Job Queuing:&lt;/strong&gt; Push data sync tasks to a queue backend (like AWS SQS or BullMQ).&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;State Management:&lt;/strong&gt; Track extraction states dynamically in a database like MongoDB or PostgreSQL to handle retries for failed API responses.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Payload Sanitization:&lt;/strong&gt; Run the incoming arrays through a dedicated transformer layer to map differing currency codes, timezone variations, and metric naming conventions into a unified dashboard layout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;Building the Future of Reporting&lt;/h3&gt;

&lt;p&gt;Building out reliable, fault-tolerant infrastructure that constantly listens to fluctuating external APIs takes significant time and architectural planning. Therefore, that is the exact reason we built &lt;a href="https://raisereturn.com" rel="noopener noreferrer"&gt;RaiseReturn&lt;/a&gt;—to give agencies enterprise-level reporting infrastructure right out of the box without forcing them to spend months writing custom ETL pipelines.&lt;/p&gt;



&lt;p&gt;If you are currently building data pipelines or wrestling with marketing API schemas, let's talk architecture in the comments below!&lt;/p&gt;`

</description>
      <category>ai</category>
      <category>saas</category>
      <category>automation</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
