The message usually comes from finance, not from monitoring. A vendor line on the card went up again, nobody shipped a feature that month, and now somebody wants an explanation by Friday.
Here is the awkward part: nothing was broken. No alert fired, no error rate moved, no dashboard turned red. Third-party API spend is one of the few production signals with no failure mode attached to it. A background job that polls a vendor every thirty seconds and gets back the exact same payload it got last time looks, from the outside, identical to a healthy integration. It just costs money every single time.
This post is about how to reduce SaaS API costs by changing the shape of your calls rather than by renegotiating your contract. Six levers, in the order I’d pull them: read the billing unit, kill pointless polling, collapse N+1 patterns, cache honestly, stop your own retry amplification, and attribute spend so you can tell whether any of it worked.
Start with the billing unit, not the call count
The single most common mistake I see is optimising HTTP requests when the vendor doesn’t bill HTTP requests. Before you touch any code, read the pricing page carefully enough to answer one question: what is the countable thing?
It varies more than people expect:
- Per request. The obvious one. Geocoding, enrichment, most REST endpoints.
- Per session. Google Maps Platform bills Places Autocomplete this way when you pass a session token: the whole keystroke stream plus the terminating Place Details call is grouped and billed as one unit. Omit the token, or reuse one across sessions, and every keystroke request is billed individually.
- Per token issued. Auth0’s machine-to-machine quotas count access tokens obtained through the client credentials flow, not the API calls you make with them. A service that fetches a fresh token on every outbound request is burning the meter for no reason.
- Per input and output token. LLM providers. Output is typically priced higher than input, which makes “be concise” a cost control and not just a style note.
- Per record, event, or message. Twilio, SendGrid, most streaming and messaging platforms. One API call can carry many billable units.
- Not at all. Stripe doesn’t bill you per API call; the constraint there is a rate limit, not a line item. Optimising those calls buys you reliability headroom, not money.
Getting this wrong sends you down the wrong path for weeks. Batching requests against an endpoint that bills per record changes your request count and leaves the invoice exactly where it was. Read the unit first.
Lever one: stop polling for things that rarely change
This is almost always the biggest single line, and it’s invisible because it’s steady. A sync job on a five-minute cron is 288 calls a day per resource. Multiply by resources, by environments, by the replicas you quietly scaled to three, and you get a number with no relationship at all to how often the data changed.
Conditional requests, when the vendor honours them
If the API returns an ETag or Last-Modified header, store it and send it back on the next poll as If-None-Match or If-Modified-Since. If nothing changed you get a 304 Not Modified with an empty body.
# First call: capture the ETag from the response headers
curl -sS -D - -o /dev/null
-H "Authorization: Bearer $TOKEN"
https://api.github.com/repos/OWNER/REPO/pulls
# Second call: send it back and check the status code only
curl -sS -o /dev/null -w '%{http_code}n'
-H "Authorization: Bearer $TOKEN"
-H 'If-None-Match: "PASTE_THE_ETAG_HERE"'
https://api.github.com/repos/OWNER/REPO/pulls
-D - dumps response headers to stdout so you can see the etag value. -w '%{http_code}' on the second call prints just the status, which is all you care about: 304 means the resource is unchanged and you saved a payload.
Whether that saves money depends on the vendor. GitHub documents that a conditional request returning 304 doesn’t count against your primary rate limit when it’s correctly authorised. Plenty of other vendors return 304 happily and bill it anyway. Test against your own invoice before you assume.
Three things that bite people with ETags:
- They’re per-page on paginated endpoints. A 304 on page one says nothing about pages two through five, so store one ETag per page.
- They can be scoped to the credential. Rotate a token and your stored ETags may stop matching, which quietly turns your conditional requests back into full ones.
- GraphQL endpoints generally don’t do conditional requests at all. You cache those yourself, keyed on a hash of the query and its variables.
Webhooks, when they exist
The proper fix is to stop asking. If the vendor supports webhooks, the call count collapses from “one per interval” to “one per actual change”, which is usually a difference of two orders of magnitude on a slow-moving resource.
Be honest about what it costs, though. Webhooks need a publicly reachable endpoint, signature verification, idempotent handling because vendors redeliver, a dead-letter path, and a reconciliation sweep for deliveries that never arrived. On a resource that changes every few seconds, polling is simpler and the savings are small. On one that changes twice a week, the webhook pays for itself immediately.
A middle option people forget: many APIs expose a cheap “has anything changed” endpoint, a sync cursor, or a delta token. Poll that on a tight interval and only fetch the expensive payload when it moves.
Lever two: collapse the N+1 pattern
You know this one from databases, and it shows up identically against third-party APIs. Fetch a list of 200 items, then fetch each item’s detail individually: that’s 201 billable calls where the vendor probably offers a way to do it in one or two. Look for:
- Expansion or embed parameters. Many REST APIs let you inline related objects in the list response so the detail calls disappear entirely.
- Field selection. Asking for fewer fields is free on per-request billing but matters a lot where the field tier drives the price. Google Places is explicit about this: requesting a single higher-tier field upgrades the whole response to that tier’s pricing.
- Bulk and batch endpoints. Salesforce, Algolia, most CRMs and search platforms have one. They’re usually documented in a corner nobody reads.
- Larger pages. If the endpoint accepts a page size of 200 and you’re defaulting to 25, you’re paying eight times as many calls to fetch identical data.
The page-size one is the cheapest win in this entire post and it’s a one-line change. Check your client library defaults today.
Lever three: cache with a boring, honest TTL
Caching third-party responses is the lever everyone reaches for first and gets subtly wrong. Three failure modes matter more than the cache itself.
Stampede. A popular key expires, fifty concurrent requests all miss, and fifty identical calls go out to the vendor. You just paid fifty times for one refresh, at the worst possible moment.
No negative caching. A lookup that returns “not found” is usually not cached at all, so the same failing lookup hammers the vendor forever. Cache your 404s. Short TTL, but cache them.
Cache key design. If your key doesn’t include the credential or tenant, you will eventually serve one customer’s data to another. This is the one mistake in this post that turns a cost problem into an incident.
You don’t necessarily need application code for this. An Nginx instance in front of the vendor handles all three, and it’s the approach I reach for first when several services hit the same API:
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:20m
max_size=1g inactive=60m use_temp_path=off;
server {
listen 127.0.0.1:8080;
location / {
proxy_pass https://api.vendor.example;
proxy_set_header Host api.vendor.example;
proxy_cache api_cache;
proxy_cache_key "$request_method$request_uri$http_authorization";
proxy_cache_valid 200 5m;
proxy_cache_valid 404 30s;
proxy_cache_lock on;
proxy_cache_use_stale updating error timeout;
proxy_cache_background_update on;
add_header X-Cache-Status $upstream_cache_status;
}
}
Reading that from the top down, because every directive is there for a reason:
proxy_cache_keyincludes$http_authorization, so responses are isolated per credential. That’s the tenant-leak fix.proxy_cache_valid 404 30sis the negative caching. Failed lookups stop being free traffic for the vendor.proxy_cache_lock onis the stampede fix. Only the first request for a given key goes upstream; the rest wait for it.proxy_cache_use_stale updatingplusproxy_cache_background_update onserves the stale copy while a single background request refreshes it. Latency stays flat and you don’t pay a thundering herd at every expiry.X-Cache-Statusgives youHIT,MISS,STALEorUPDATINGin the response so you can actually measure your hit rate instead of guessing at it.
The trade-off is worth stating plainly: you’re now serving data that can be five minutes stale, and you own another moving part. Fine for a currency rate or a product catalogue. Not fine for an inventory count on a checkout page. Set the TTL from how wrong the data is allowed to be, never from how much you want to save.
Where the caller is distributed, Redis with a short lock key does the same job in application code, and a small always-on VPS from something like InterServer or DigitalOcean is plenty to host either the proxy or the cache. At the edge, Cloudflare Workers with KV lets you cache vendor responses close to users, which helps when the calls originate from browsers rather than your backend.
Lever four: stop paying for your own retries
This produces the sudden spike rather than the slow creep, and people miss it because retries feel like resilience.
The vendor wobbles. Every client retries. With a fixed delay they retry in lockstep, so the vendor gets a synchronised wave on top of normal load, struggles longer, and generates more retries. AWS’s architecture write-up on exponential backoff and jitter is the canonical reference, and its central finding is worth repeating: backoff alone still leaves visible clusters of calls. Randomising each client’s delay is what decorrelates the herd, and in their simulation it cut total call count substantially.
import random, time
BASE = 0.5 # seconds
CAP = 30.0 # never sleep longer than this
def sleep_before_retry(attempt):
# capped exponential backoff, then full jitter
window = min(CAP, BASE * (2 ** attempt))
time.sleep(random.uniform(0, window))
The cap stops a long outage turning into hour-long sleeps. The random.uniform(0, window) is the jitter: two processes that failed at the same instant now wake at different times. Note that full jitter can occasionally produce a near-zero delay, which is fine for transient errors but too aggressive for throttling responses that reset on a per-minute window. For a 429, honour the Retry-After header if the vendor sends one, and use a jitter formula with a guaranteed floor if they don’t.
Three more rules that cost nothing to implement:
- Never retry a 4xx except 429. A 400 or a 422 will fail identically forever. Every one of those retries is a billed call buying you nothing.
- Cap retries as a fraction of traffic. A retry budget (say, retries may not exceed some small percentage of total requests) is the only control that sees the system-wide picture. Per-client backoff can’t.
- Check your layers. If the SDK retries three times, your HTTP client retries three times, and your job runner retries three times, one logical operation is up to twenty-seven calls. This stacking is extremely common and almost never intentional.
For writes, retrying safely needs an idempotency key so the vendor deduplicates rather than creating two of whatever you asked for. Stripe’s Idempotency-Key header is the pattern most others copied. Without it, teams disable retries on writes entirely and then eat manual reconciliation work instead.
Lever five: move latency-tolerant work off the synchronous path
Ask a blunt question about each integration: does a human wait on this response?
Nightly enrichment, classification, report generation, bulk address validation, embedding backfills. Nobody is waiting. Several vendors, including the major LLM providers, offer asynchronous batch endpoints at a discount to the synchronous rate in exchange for a looser delivery window. Rates move, so check current terms rather than a number you read somewhere, but the mechanism is stable and the discount is usually substantial.
Even without a batch endpoint, moving work behind a queue lets you coalesce. Ten events touching the same customer record within a minute become one enrichment call instead of ten. That deduplication window is often worth more than the batch discount.
Lever six: attribute the spend or you’re guessing
Everything above is unfalsifiable until you can answer: which feature, which tenant, which job. The invoice gives you the total and never the cause. What I’d put in place, in order:
- Route outbound calls through one place. A shared HTTP client wrapper or the caching proxy above. If every service instantiates its own client, you have no chokepoint and no measurement.
- Emit a counter per call with labels for vendor, endpoint, status class, and cache result. Keep the label set small; per-tenant labels will blow up your metrics cardinality and trade one bill for another.
- Build one unit-cost metric. Vendor spend per active tenant per month. It’s the only number that survives growth, because raw spend going up during a good quarter is not a problem.
- Alert on call rate, not on the bill. A rate alert fires in minutes. An invoice fires in thirty days. Grafana, Prometheus, Datadog, CloudWatch, whatever you already run is fine; the point is that the alert exists at all.
- Track cache hit rate as a first-class SLI. When someone changes a cache key format and the hit rate silently drops to zero, this is the graph that tells you before the invoice does.
Troubleshooting: the bill jumped and nothing shipped
Work down this list. In my experience the cause is usually in the first four.
- Replica count changed. Autoscaling or a Kubernetes replica bump multiplies every cron-driven poller. Check whether your scheduled jobs have leader election, or whether all five pods are dutifully polling the same endpoint.
- Cache hit rate collapsed. A changed key format, a Redis eviction policy under memory pressure, or a TTL someone dropped “temporarily” during debugging. Graph the hit rate over the period, not the current value.
- Retry loop against a persistent 4xx. Look for a flat, high rate of identical failing calls. This has the distinctive signature of high call volume with zero successful responses.
- A non-production environment pointed at production credentials. Staging, a CI pipeline, or an integration test suite running on every commit. Check whether calls are arriving from IPs you don’t recognise.
- Session or token handling regressed. A refactor that dropped a session token parameter, or a service that stopped caching its auth token and now mints a new one per request.
- Legitimate growth in an expensive segment. A few large customers using a costly feature move the bill without moving call count much. Not a bug, but worth ruling in before you go hunting for one.
If the vendor exposes per-key usage breakdowns in their dashboard, split your credentials by service before your next incident. Separate API keys per environment and per major consumer turn a whodunnit into a two-minute lookup.
Common mistakes
- Optimising the endpoint that’s easy to change rather than the one that dominates the bill. Rank by spend first.
- Caching without a per-credential key, and finding out the hard way in a multi-tenant system.
- Treating 304 responses as free when your specific vendor bills them anyway.
- Setting TTLs from a savings target rather than a staleness tolerance, and finding out during a customer escalation.
- Leaving retry logic enabled at three separate layers and calling the result “defence in depth”.
- Adding per-tenant labels to outbound-call metrics and moving the cost from the API vendor to the observability vendor.
- Shipping the optimisation and never checking the next invoice against the prediction.
Best practices worth keeping
- One outbound HTTP client wrapper per service, with timeouts, retries, and metrics configured in exactly one place.
- Separate API credentials per environment and per major consumer, so usage dashboards mean something.
- Cache auth tokens until shortly before expiry. Refresh early, not on every call.
- Add jitter to scheduled jobs as well as to retries. Everything set to run at the top of the hour arrives at the vendor as a spike.
- Write down the staleness tolerance for each cached resource next to its TTL, so the next person doesn’t have to reverse-engineer your reasoning.
- Re-check the pricing page after any vendor product update. Billing units change more often than endpoints do.
FAQ
How do I find out which service is driving my API spend?
Start with the vendor’s own usage dashboard broken down by API key. If all your services share one key, that’s the first thing to fix. Failing that, log every outbound call at the client wrapper with the endpoint and calling service, and aggregate for a week. A week of honest logs beats a month of theorising.
Do ETags actually reduce SaaS API costs, or just help with rate limits?
It depends entirely on the vendor. GitHub documents that an authorised conditional request returning 304 doesn’t count against the primary rate limit. Others count every request regardless of status. Run a controlled test: a known number of conditional calls over a billing period, then compare against the usage report. Don’t assume either way.
Is caching third-party API responses allowed?
Usually yes, but not always, and some terms of service restrict how long you may store certain data. Mapping and place data is a common example of restricted caching. Check the terms for the specific vendor before you build a permanent store, particularly if you’re caching anything personally identifiable.
How much can caching realistically cut an API bill?
Nobody can give you a number without knowing your traffic. Estimate it yourself: for a candidate endpoint, count distinct request keys versus total requests over a day. That ratio is roughly your ceiling for hit rate. Multiply by the endpoint’s share of spend. If the answer is a couple of percent, spend your time elsewhere.
Should I always replace polling with webhooks?
No. The comparison is change frequency against poll frequency. If the resource changes almost as often as you poll, you save little and take on signature verification, redelivery handling, and a reconciliation job. Webhooks win decisively on slow-changing resources and on anything where you’re polling more than a handful of objects.
Does rate limiting my own users reduce vendor costs?
Only when user actions map directly to vendor calls, such as a search box that fires an upstream request per keystroke. Debouncing input and enforcing per-plan quotas helps a lot there. For background processing driven by your own schedules, user rate limits change nothing.
Should I disable retries to save money?
No. Fix them instead. Capped exponential backoff with jitter, no retries on non-429 client errors, a retry budget, and retry logic living at exactly one layer. Disabling retries entirely trades a cost problem for a reliability problem, and reliability problems are more expensive.
The one thing worth remembering
Most efforts to reduce SaaS API costs fail because they start with code. They should start with two questions: what is the vendor actually counting, and which of my calls returned information I already had?
Answer those honestly and the work usually collapses to a handful of unglamorous changes. Raise a page size. Cache a token. Put a lock around a cache refresh. Delete a poller that was watching something that changes twice a week. None of it is clever. All of it shows up on the next invoice, which is the only review that counts.
Need help getting your API spend under control?
This is work I take on regularly. Typical engagements look like:
- Auditing outbound API traffic to find which endpoints, jobs and tenants are driving the bill, and ranking them by spend rather than by how obvious they are.
- Building a caching layer in front of chatty vendor APIs, with Nginx, Redis or Cloudflare Workers, including stampede protection and per-tenant key isolation.
- Replacing polling jobs with webhook receivers or delta-token syncs, with signature verification, idempotent handling and a dead-letter path.
- Auditing timeout and retry configuration across services to remove stacked retries and add proper backoff, jitter and retry budgets.
- Moving latency-tolerant workloads onto batch or queue-based paths, with deduplication windows that cut call volume before it reaches the vendor.
- Setting up call-rate and cache-hit-rate dashboards and alerts in Prometheus, Grafana, Datadog or CloudWatch so the invoice is never the first signal.
If you’ve got a cost graph that’s climbing, a job you suspect is over-polling, or a client wrapper you’re not sure about, send it over and I’ll tell you what I’d look at first.