The weekly client SEO report is the deliverable agencies most often promise and least often actually send. The work isn't hard — pull the audit, format the numbers, write a paragraph — it's just tedious enough that it slides to the bottom of the week. So it doesn't get sent. So the client churns at month six because they "don't see the value."

This post is the ~80-line setup for automating the weekly client SEO report end-to-end with the SEO report API. Once it's running, the only manual work is reading what got sent on Monday so you're not surprised when the client asks about it.

What "good" looks like for a weekly report

The right scope is small. A weekly SEO report should be readable in 90 seconds and answer four questions:

  1. Did our score go up or down this week?
  2. Which pages got worse?
  3. Which pages got better?
  4. What's the one thing we should fix next?

That's it. Anything longer doesn't get read. Anything shorter doesn't justify the email.

The setup

You need three things:

  • An API key (Starter plan minimum — you'll burn ~30 audits a week per client)
  • A list of the client's important URLs (typically 20–40 pages — homepage, top landing pages, key blog posts)
  • Somewhere to store last week's scores so you can diff against them

For storage, anything works — a JSON file on disk, a SQLite DB, a Notion page. We'll use a JSON file because it's the least magical thing that works.

The script

# weekly_report.py
import json, os, smtplib, statistics
from datetime import date
from email.message import EmailMessage
from pathlib import Path
from seoscoreapi import audit

CLIENT = "acme.com"
PAGES = [
    f"https://{CLIENT}/",
    f"https://{CLIENT}/pricing",
    f"https://{CLIENT}/features",
    # ... 30 more
]
KEY = os.environ["SEO_KEY"]
HISTORY = Path(f"history/{CLIENT}.json")

# 1. Run this week's audits
this_week = {url: audit(url, api_key=KEY) for url in PAGES}

# 2. Load last week's scores
last_week = json.loads(HISTORY.read_text()) if HISTORY.exists() else {}

# 3. Compute deltas
rows = []
for url, result in this_week.items():
    prev = last_week.get(url, {}).get("score")
    cur = result["score"]
    delta = (cur - prev) if prev is not None else 0
    rows.append({"url": url, "score": cur, "delta": delta,
                 "priorities": result.get("priorities", [])[:1]})

avg = statistics.mean(r["score"] for r in rows)
regressions = sorted([r for r in rows if r["delta"] < -2], key=lambda r: r["delta"])[:5]
wins = sorted([r for r in rows if r["delta"] > 2], key=lambda r: -r["delta"])[:3]

# 4. Build the email
body = [
    f"Weekly SEO report for {CLIENT} — {date.today()}",
    f"\nAverage score: {avg:.0f}",
    f"\n## Regressions ({len(regressions)})",
    *[f"  {r['url']}: {r['score']} ({r['delta']:+d})" for r in regressions] or ["  None — great week."],
    f"\n## Wins ({len(wins)})",
    *[f"  {r['url']}: {r['score']} ({r['delta']:+d})" for r in wins] or ["  None this week."],
    "\n## Top fix to ship this week",
    f"  {regressions[0]['priorities'][0]['issue'] if regressions else 'Nothing urgent.'}",
]

msg = EmailMessage()
msg["Subject"] = f"[{CLIENT}] Weekly SEO — avg {avg:.0f}"
msg["From"] = "reports@youragency.com"
msg["To"] = "client@acme.com"
msg.set_content("\n".join(body))

with smtplib.SMTP("smtp.gmail.com", 587) as s:
    s.starttls()
    s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
    s.send_message(msg)

# 5. Save this week as next week's baseline
HISTORY.parent.mkdir(exist_ok=True)
HISTORY.write_text(json.dumps({u: {"score": r["score"]} for u, r in this_week.items()}, indent=2))

That's the entire script. ~80 lines, one cron job (0 9 * * MON python weekly_report.py), and the client gets a real weekly report from then on.

Per-client setup

For agencies with multiple clients, the move is a YAML manifest:

- domain: acme.com
  recipient: client@acme.com
  pages: [/, /pricing, /features]
- domain: brand-b.com
  recipient: marketing@brand-b.com
  pages: [/, /shop, /about]

…and loop over the manifest in the cron job. Adding a client is one PR. The same script handles 1 or 100.

Branding it

The example above sends plain text. For a branded HTML report, drop the body assembly into a Jinja template, embed the agency logo, and use msg.add_alternative(html, subtype="html"). ~20 more lines and you've got the report agencies usually charge $500/mo for.

If you want shareable client links instead of emails, link to /report/{domain} on seoscoreapi.com — that's the hosted report URL, updated whenever the audit re-runs.

Why this retains clients

The honest reason agencies lose SEO retainers at month six isn't because the work is bad — it's because the client can't see the work. The weekly automated report makes the work visible.

The marginal cost of sending it is zero (the cron runs whether you click anything or not). The marginal benefit is the client never wonders what they're paying for. That ratio is the entire case for the weekly report.

If you're running 5 client retainers, ~5 hours of setup buys you ~5 hours/week back forever. After the second week, it's pure margin.