An API with zero calls in your metrics isn't dead. It's an API your metrics can't see calling.
Most of us know this. Few of us check it before we delete something.
This post is a practical checklist for finding zombie APIs (deployed, unused APIs) without retiring one that a quarterly batch job still depends on. It covers a simple classifier, example queries for four gateways, the edge cases that produce false zombies and a safe retirement flow.
TL;DR
Every API gets one of three statuses:
| Status | Rule |
|---|---|
ACTIVE | Consumer calls recorded in the last 90 days |
ZOMBIE | Traffic data covers the full 90 days, and consumer calls = 0 |
UNKNOWN | No traffic source, or less than 90 days of data |
The key line: if you can't measure it, it's UNKNOWN, not ZOMBIE.
Why 90 days?
- 30 days flags every month-end and quarter-end job as dead between runs.
- 365 days means paying for real zombies for a year before you act.
- 90 days catches monthly and most quarterly callers. It's still a candidate, not a verdict. Yearly jobs need an owner check.
The classifier
Here's the logic as Python. The interesting part isn't the zero check. It's everything before it.
from datetime import datetime, timedelta, timezone WINDOW = timedelta(days=90) def classify(api, now=None): now = now or datetime.now(timezone.utc) # 1. No traffic source at all: we can't see it, so we can't judge it. if not api.traffic_source: return "UNKNOWN", "no traffic source connected" # 2. API is younger than the window. if now - api.created_at < WINDOW: return "UNKNOWN", "less than 90 days old" # 3. Metrics/log retention is shorter than the window. if api.traffic_source.retention < WINDOW: return "UNKNOWN", "retention shorter than 90 days" # 4. Count consumer calls only (exclude health checks and synthetic monitors). calls = api.traffic_source.count_calls( since=now - WINDOW, exclude_user_agents=["kube-probe", "ELB-HealthChecker", "synthetic-monitor"], ) if calls == 0: return "ZOMBIE", "no consumer calls in 90 days" return "ACTIVE", f"{calls} consumer calls in 90 days" Notice that three of the four branches return UNKNOWN. That's on purpose. In most estates, fixing the unknowns is a bigger job than retiring the zombies.
Getting 90-day call counts per gateway
Examples for four common gateways. Adjust names and versions to your setup.
AWS API Gateway (CloudWatch)
The Count metric is the total number of API requests in a period.
aws cloudwatch get-metric-statistics \ --namespace AWS/ApiGateway \ --metric-name Count \ --dimensions Name=ApiName,Value=orders-v1 \ --start-time "$(date -u -d '-90 days' +%Y-%m-%dT%H:%M:%SZ)" \ --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --period 86400 \ --statistics Sum The date -d syntax is GNU (Linux). On macOS, use date -u -v-90d +%Y-%m-%dT%H:%M:%SZ.
Gotcha: CloudWatch keeps daily data points (period ≥ 3600s) for 455 days, so 90 days is fine. But Count includes everything, including health checks that hit the API, so it can make a dead API look alive.
Azure API Management (Log Analytics, KQL)
ApiManagementGatewayLogs | where TimeGenerated > ago(90d) | where Url !contains "/health" | summarize calls = count() by ApiId | order by calls asc Gotcha: health checks are filtered by path here, because the table has no user-agent column. This only works if diagnostic logs are sent to Log Analytics, and the workspace keeps at least 90 days. Default retention is often 30 days. If yours is 30, every API is UNKNOWN for this purpose.
Kong (Prometheus plugin)
sum by (service) ( increase(kong_http_requests_total[90d]) ) Gotcha: check your Prometheus retention. Many setups keep 15 days by default, so a [90d] range quietly returns partial data. Metric names also vary by Kong and plugin version.
Apigee (stats API)
curl -s -H "Authorization: Bearer $TOKEN" \ "https://apigee.googleapis.com/v1/organizations/$ORG/environments/$ENV/stats/apiproxy?select=sum(message_count)&timeRange=$START~$END&timeUnit=day" Gotcha: run it per environment. An API with zero traffic in prod but traffic in a partner-facing environment isn't a zombie.
Edge cases that produce false zombies
These are the ones that cause real outages.
1. Calendar jobs: Quarterly reconciliations, annual tax or reporting APIs, disaster-recovery endpoints. Silent for months by design. Keep a list of known long-cycle APIs and exclude them from automatic flags.
2. Traffic that bypasses the gateway: Services that call the backend directly by IP, by internal DNS or through a service mesh. The gateway sees nothing. Check mesh metrics or backend access logs before you trust a zero.
3. Health checks that hide zombies: The opposite problem. Kubernetes probes and load-balancer checks hit the endpoint every few seconds, so a dead API looks busy. Filter them out by user agent or path.
4. Versioned paths: /v1/orders shows zero calls, /v2/orders is busy. That's fine, /v1 might be a zombie. But make sure your grouping doesn't merge versions, or the zombie hides inside the active total.
5. Multiple environments and regions: Count across every environment and region where the API is deployed, or you'll flag a regional API that's busy somewhere else.
A safe retirement flow
Once something is confirmed ZOMBIE and the owner agrees:
- Mark it deprecated: Send a
Deprecationheader (RFC 9745) and update your catalog. - Set a date: Send a
Sunsetheader (RFC 8594):
HTTP/1.1 200 OK Deprecation: @1796083200 Sunset: Thu, 01 Apr 2027 00:00:00 GMT Link: <https://developer.example.com/migrations/orders-v2>; rel="sunset" - Brownout: Return
503for short, announced windows (say, 15 minutes, twice a week). Hidden callers will show up in your logs or your inbox. - Remove the route and return
410 Gonefor a few weeks, so late callers get a clear signal instead of a timeout. - Clean up the backend, credentials, DNS and catalog entry. Record what it cost, so the next cleanup has a number to point to.
Automating it
A small scheduled job gets you most of the way:
- Pull the API list from each gateway and your spec repo.
- Attach a traffic source to each API, and record its retention.
- Run the classifier weekly.
- Post
ZOMBIEcandidates to the owner's channel, and postUNKNOWNAPIs to the platform team as data gaps to fix.
If you'd rather not build and maintain this across several gateways, some API management platforms now do it for you. DigitalAPI, for example, applies the same 90-day rule across connected gateways and marks unmeasurable APIs as unknown rather than zombie.
Over to you
The classifier is easy. The edge cases are where the stories are.
What's the longest gap you've seen between calls to an API that turned out to matter?
