SEO Audit Retainer: Turn One-Off Audits Into Monthly Work
How to turn a one-off SEO audit into a monthly retainer: three retainer models, pricing math, report structure and data on how fast sites change week to week.
An SEO audit retainer turns a one-off audit into a recurring service: you re-scan the client's site on a schedule, catch regressions before they cost traffic, implement or oversee fixes, and report progress every month. It works when the client can see that their site keeps changing — and that someone is watching. It fails when the monthly report says "nothing to report".
The case for watching is stronger than most clients assume. On 3 and 10 October 2026 we ran the identical audit on the same 197 randomly sampled homepages, one week apart. In seven days 45.2% loaded a different number of JavaScript files, 21.8% changed their third-party JavaScript weight by more than 10%, 23.9% had a different number of failing accessibility elements, and 4.6% regressed outright — a new failing accessibility rule or a new broken internal link. That's one site in twenty breaking something in a single week, without anyone deciding to.
This guide covers why one-off audits churn, three retainer models with pricing math, a monthly report structure, the churn triggers to watch, and a script that snapshots key pages and reports what changed.
Why One-Off Audits Churn
A one-off audit has a built-in ending: the client gets a report, fixes some of it, and the relationship stops. Three things make it hard to continue:
- The value is front-loaded. The audit's insight is delivered on day one; there's nothing new to sell on day thirty unless you create it.
- Fixes decay. Templates change, plugins update, marketing adds tags. The audit becomes out of date within weeks, and nobody notices until traffic drops.
- No proof loop. Without a re-audit, neither side can show what the work achieved, so the next budget conversation starts from zero.
A retainer fixes all three if it's built around re-measurement, not around hours.
Data: How Much Sites Change in One Week
| Change between 3 and 10 October 2026 (same homepage, same audit) | Sites | Share of 197 |
|---|---|---|
| Number of JavaScript files changed | 89 | 45.2% |
| Internal link set changed by >10% | 73 of 192 | 38.0% |
| Image count changed | 52 | 26.4% |
| Failing accessibility elements changed | 47 | 23.9% |
| Third-party JavaScript bytes changed by >10% | 43 | 21.8% |
| Total JavaScript bytes changed by >10% | 41 | 20.8% |
| A failing accessibility rule was fixed | 16 | 8.1% |
| Visible word count changed by >10% | 13 | 6.6% |
| A new failing accessibility rule appeared | 6 | 3.0% |
| A new broken internal link (404/410/5xx) | 3 | 1.5% |
| Page title changed | 3 | 1.5% |
| Regression: new accessibility rule failure or new broken link | 9 | 4.6% |
Method. The sample is the 219 random homepages (Tranco 94GG2, ranks 1,001–50,000) from our website accessibility statistics. Both snapshots used the same code: a Chromium render, axe-core WCAG A/AA rules, CDP JavaScript coverage and checks of up to 150 internal links. 197 homepages loaded on both dates; 22 timed out on the second run under heavier load on our side and were excluded rather than counted as changes.
Read the table in two layers, keeping in mind that Google measures page experience on real users over time (see Core Web Vitals), so week-to-week changes accumulate into what search sees. The top rows are mostly churn — rotating ads, tag managers loading different vendors, carousels and personalized modules. Churn isn't damage, but it's the raw material of regressions: every new script is a chance to slow the page or fire before consent. The bottom rows are damage. If 4.6% per week held steady (a projection, not something we measured), close to half of sites (46%) would ship at least one such regression within a quarter, usually without anyone noticing. Note the good news too: 8.1% fixed a failing rule in the same week. Sites improve when someone is paying attention. For the indexing side of monitoring, pair the scans with Search Console's Page indexing report.
Three Retainer Models
| 1. Monitoring | 2. Fix implementation | 3. Hybrid | |
|---|---|---|---|
| What the client gets | Scheduled re-audits, regression review, monthly report, quarterly roadmap | A monthly bank of hours to fix the backlog | Monitoring plus capped fix hours each month |
| Best for | Clients with in-house developers | Clients without developers | Most small and mid-size clients |
| Your monthly hours (typical) | 2–4 | 8–20 | 6–10 |
| Main risk | "Nothing happened" months | Hours used on non-SEO requests | Scope creep across the cap |
| Proof of value | Regressions caught; trend charts | Tickets closed and re-audit deltas | Both |
Pricing math (with an example rate)
Google's own advice on hiring an SEO warns clients about guaranteed rankings, so price retainers from hours and tooling, not from a market average — your rate, your client's size and your market vary too much for a universal number. The structure:
monthly fee = (monitoring hours + fix hours) × your rate + tooling cost per clientWorked example at an example rate of $90/hour, with BugViso as the tooling:
| Model | Hours / month | Labour | Tooling share | Monthly fee |
|---|---|---|---|---|
| Monitoring | 3 | $270 | ~$5 | ~$275 |
| Hybrid | 3 + 6 | $810 | ~$5 | ~$815 |
| Fix implementation | 2 + 14 | $1,440 | ~$5 | ~$1,445 |
The tooling share is real arithmetic, not a guess. A weekly scheduled scan is about 4.3 runs per client per month, and each scheduled run uses one scan credit. Ten clients therefore need about 43 scans a month, which fits BugViso's Agency Starter plan (65 scans for $49/month) — roughly $5 per client. Swap in your own rate and hours; the point is that the fee is explainable line by line. For one-off pricing, see our guide on how to price SEO audit services.
The Monthly Report: One Page, Four Sections
# [Client] — October website health report
## 1. Headline
Health score 71 → 78. Two regressions caught and fixed within a week. Mobile LCP improved 1.2 s.
## 2. What changed this month (from the scheduled scans)
- ✗ New: a chat widget added 380 KB of third-party JavaScript to every page (week 2) → deferred, fixed week 3
- ✗ New: /pricing briefly shipped with noindex after a CMS update (week 3) → fixed same day
- ✓ Fixed: 11 footer tap targets now pass WCAG 2.5.8
## 3. Progress on the roadmap
| Item | Status | Evidence |
| Consent: tags wait for opt-in | Done | 0 tracking cookies pre-consent |
| Unused JavaScript | In progress | 9.3 MB → 6.1 MB |
## 4. Next month
Top 3 items, owner, and the next scan dates.The numbers in that template are illustrative; fill them from the client's scans. Keep sections 2 and 3 factual — this is where the client sees the retainer working. Our guide to presenting audit results to clients covers wording.
Churn Triggers to Watch
These are the warning signs that a retainer is about to be cancelled, and what to do about each:
| Trigger | What it looks like | Response |
|---|---|---|
| "Nothing to report" months | Reports repeat last month | Add a forward-looking item every month: a test, a template audit, a competitor comparison |
| Unread reports | No replies, no questions | Lead with one sentence of business impact; book a 15-minute call each quarter |
| Invisible wins | Fixes happen but nobody connects them to results | Show before/after for every fix; tie it to a metric the client watches |
| Scope creep | Retainer hours spent on unrelated requests | Track hours by category; propose a separate project when non-SEO work exceeds a set share |
| Champion leaves | Your contact changes jobs | Send the new owner a one-page "what we do and what it's achieved" summary in the first week |
Snapshot and Diff Key Pages: Script
A scheduled audit covers the full site; this script adds a fast weekly diff of the handful of pages that matter most — homepage, top templates, money pages — so a dangerous change is caught within days. It stores a JSON snapshot of the SEO-critical signals per page and reports differences, most dangerous first.
#!/usr/bin/env python3
"""change_monitor.py: weekly snapshot + diff of the SEO-critical signals on a client's key pages.
Usage:
python3 change_monitor.py snapshot pages.txt snapshots/2026-10-10.json
python3 change_monitor.py diff snapshots/2026-10-03.json snapshots/2026-10-10.json
pages.txt: one URL per line (homepage, top templates, money pages).
Standard library only. Records status, final URL, title, meta description, robots meta and
X-Robots-Tag, canonical, H1s, word count, internal link count, script hosts and JSON-LD types,
then reports what changed between two snapshots, most dangerous changes first. Exit code 1 when
a critical change (noindex, status, canonical, lost H1/title) was found.
"""
import html as htmllib, json, re, ssl, sys, urllib.error, urllib.request
from urllib.parse import urljoin, urlsplit
UA = "Mozilla/5.0 (compatible; change-monitor/1.0)"
CTX = ssl.create_default_context()
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=25, context=CTX) as r:
return r.status, r.geturl(), r.read(3_000_000).decode("utf-8", "replace"), r.headers.get("X-Robots-Tag", "")
except urllib.error.HTTPError as e:
return e.code, url, "", ""
except Exception as e:
return type(e).__name__, url, "", ""
def first(pattern, html):
m = re.search(pattern, html, re.I | re.S)
return htmllib.unescape(re.sub(r"\s+", " ", m.group(1)).strip()) if m else None
def snapshot_page(url):
status, final, html, xrt = fetch(url)
host = urlsplit(final).hostname or ""
body = re.sub(r"<script.*?</script>|<style.*?</style>", " ", html, flags=re.S | re.I)
links = {urljoin(final, h).split("#")[0] for h in re.findall(r"<a\b[^>]*href=[\"']([^\"'#]+)", html, re.I)}
scripts = {urlsplit(urljoin(final, s)).hostname for s in re.findall(r"<script[^>]+src=[\"']([^\"']+)", html, re.I)}
types = sorted(set(re.findall(r'"@type"\s*:\s*"([^"]+)"', " ".join(
re.findall(r"<script[^>]+application/ld\+json[^>]*>(.*?)</script>", html, re.I | re.S)))))
return {
"status": status, "final_url": final,
"title": first(r"<title[^>]*>(.*?)</title>", html),
"description": first(r"<meta[^>]+name=[\"']description[\"'][^>]*content=[\"']([^\"']*)", html),
"robots": ((first(r"<meta[^>]+name=[\"']robots[\"'][^>]*content=[\"']([^\"']*)", html) or "") + " " + xrt).strip().lower(),
"canonical": first(r"<link[^>]+rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)", html),
"h1": [htmllib.unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", h))).strip() for h in re.findall(r"<h1\b[^>]*>(.*?)</h1>", html, re.I | re.S)],
"words": len(re.sub(r"<[^>]+>", " ", body).split()),
"internal_links": sum(1 for l in links if (urlsplit(l).hostname or "") == host),
"script_hosts": sorted(h for h in scripts if h and h != host),
"jsonld_types": types,
}
def diff(old, new):
out = [] # (severity, url, message)
for url in sorted(set(old) | set(new)):
a, b = old.get(url), new.get(url)
if not a or not b:
out.append((2, url, "page added to / removed from the monitored list"))
continue
crit = lambda m: out.append((0, url, m))
warn = lambda m: out.append((1, url, m))
info = lambda m: out.append((2, url, m))
if a["status"] != b["status"]:
crit(f"status {a['status']} → {b['status']}")
if "noindex" in b["robots"] and "noindex" not in a["robots"]:
crit(f"NOINDEX added ({b['robots']})")
if (a["canonical"] or "") != (b["canonical"] or ""):
crit(f"canonical {a['canonical']} → {b['canonical']}")
if a["final_url"] != b["final_url"]:
warn(f"now redirects to {b['final_url']}")
if a["title"] != b["title"]:
(crit if not b["title"] else warn)(f"title: {a['title']!r} → {b['title']!r}")
if a["h1"] != b["h1"]:
(crit if not b["h1"] else warn)(f"H1: {a['h1']} → {b['h1']}")
if a["description"] != b["description"]:
warn("meta description changed" if b["description"] else "meta description removed")
if a["words"] and abs(b["words"] - a["words"]) / a["words"] > 0.2:
warn(f"visible words {a['words']} → {b['words']}")
if a["internal_links"] and abs(b["internal_links"] - a["internal_links"]) / a["internal_links"] > 0.2:
warn(f"internal links {a['internal_links']} → {b['internal_links']}")
added = sorted(set(b["script_hosts"]) - set(a["script_hosts"]))
removed = sorted(set(a["script_hosts"]) - set(b["script_hosts"]))
if added:
warn(f"new third-party script hosts: {', '.join(added)}")
if removed:
info(f"third-party script hosts removed: {', '.join(removed)}")
if set(a["jsonld_types"]) - set(b["jsonld_types"]):
warn(f"structured data removed: {', '.join(sorted(set(a['jsonld_types']) - set(b['jsonld_types'])))}")
return sorted(out)
def main():
if len(sys.argv) != 4 or sys.argv[1] not in ("snapshot", "diff"):
sys.exit(__doc__)
if sys.argv[1] == "snapshot":
urls = [l.strip() for l in open(sys.argv[2]) if l.strip() and not l.startswith("#")]
snap = {u: snapshot_page(u) for u in urls}
json.dump(snap, open(sys.argv[3], "w"), indent=1)
print(f"saved {len(snap)} pages to {sys.argv[3]}")
return 0
changes = diff(json.load(open(sys.argv[2])), json.load(open(sys.argv[3])))
label = {0: "CRITICAL", 1: "warning ", 2: "info "}
for sev, url, msg in changes:
print(f"{label[sev]} {url} {msg}")
print(f"\n{sum(1 for c in changes if c[0] == 0)} critical, {sum(1 for c in changes if c[0] == 1)} warnings, "
f"{sum(1 for c in changes if c[0] == 2)} info")
return 1 if any(c[0] == 0 for c in changes) else 0
if __name__ == "__main__":
sys.exit(main())To demonstrate it, we took a real snapshot of three pages on our own site and then simulated a bad deploy in the newer snapshot — a noindex and a homepage canonical on the pricing page, a removed H1, two new tracking scripts and a lost FAQ schema. The diff:
CRITICAL https://bugviso.com/blog/wcag-2-2-checklist H1: ['WCAG 2.2 Checklist: All 9 New Criteria + the Full AA List'] → []
CRITICAL https://bugviso.com/pricing NOINDEX added (noindex, nofollow)
CRITICAL https://bugviso.com/pricing canonical https://bugviso.com/pricing → https://bugviso.com/
warning https://bugviso.com/ new third-party script hosts: connect.facebook.net, static.hotjar.com
warning https://bugviso.com/ structured data removed: FAQPage
warning https://bugviso.com/ title: 'Free Website Audit Tool: SEO, Speed & Security | BugViso' → 'Home'
3 critical, 3 warnings, 0 infoRun snapshot weekly from cron into dated files and diff the last two. The exit code makes it easy to send yourself a message only when something critical changed — the job a retainer is paid for.
How BugViso Runs the Monitoring Side
The repeatable part of a retainer is the measurement, and that's what BugViso automates:
- Scheduled scans — daily, weekly or monthly, at a time and timezone you choose, labelled with the client's name. Each run re-audits the site and lands in your audit records, with a re-audit button for ad-hoc checks after a fix.
- White-label reports on every run. On agency accounts, your saved brand kit (company name, logo, brand colour, header and footer) is applied to scheduled reports, so each month's PDF is ready to send.
- The full audit each time: Core Web Vitals and throttled-network LCP, JavaScript coverage, the pre-consent privacy audit, axe-core accessibility, the mobile pass, links, canonicals, structured data and security headers — the same categories as the table above.
- Exports of records and crawls as CSV, and of any audit as JSON, for your own trend sheets.
BugViso doesn't send score-drop alerts or produce an automatic scan-to-scan diff, so review each scheduled report yourself (or use the script above for the key pages). That review is also the human judgement your client is paying for. You can set up a scheduled scan for a client; scheduling and report fields are covered on the site crawl and white-label reports page. See also why run a site audit on a schedule.
Turning the Audit Into the Retainer: The Conversation
- Deliver the audit with a re-audit date in the proposal (template here).
- At the re-audit, show two things: what improved, and what changed that nobody planned — new scripts, new failures, new broken links. The second list sells monitoring.
- Offer the model that matches their team: monitoring if they have developers, hybrid or implementation if not.
- Start with a 3-month term, then month-to-month. Short commitments lower the barrier; the monthly report does the retention work.
Our SEO audit process template for agencies puts this into a seven-stage SOP.
FAQ
What is an SEO audit retainer?
A recurring monthly service in which you re-audit a client's site on a schedule, catch and fix regressions, work through a prioritized backlog and report progress, instead of delivering a single audit.
How much should an SEO retainer cost?
Price it from your hours and tooling: monitoring hours plus fix hours times your rate, plus tooling per client. A monitoring-only retainer typically needs 2–4 hours a month; implementation retainers need far more. Use your own rate rather than market averages.
How often should a retainer client's site be scanned?
Weekly for most sites. In our data, 4.6% of homepages regressed in a single week and over a fifth changed their third-party JavaScript weight significantly. Daily scans suit large sites with frequent deploys.
What goes in a monthly SEO retainer report?
A headline result, what changed this month (regressions caught, fixes shipped), progress on the roadmap with evidence, and next month's top three items.
Conclusion
Retainers survive on visible change, and sites provide plenty of it: one in twenty homepages in our data broke something in a single week, so sell the watching, show every regression caught and fix shipped, and let scheduled BugViso scans do the measuring.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.