Docs
API reference
Measure any public page and read the result as JSON: the same carbon, transfer and performance figures the public report shows. Two endpoints, key authentication, no client library needed. Free while Carbonless is in beta.
Quickstart
Create a key, start a scan, poll until it completes, read the numbers:
# 1. Start a scan
curl -X POST https://carbonless.cc/api/v1/scans \
-H "Authorization: Bearer $CARBONLESS_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "example.com/pricing"}'
# => { "scanId": "9f1c..." }
# 2. Poll until status is "complete" (a few seconds apart)
curl https://carbonless.cc/api/v1/scans/9f1c...
# => { "status": "complete", "rating": "B", "co2": { "perVisit": 0.140, ... }, ... }The same flow in JavaScript:
const started = await fetch('https://carbonless.cc/api/v1/scans', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.CARBONLESS_KEY}`,
'content-type': 'application/json'
},
body: JSON.stringify({ url: 'example.com/pricing' })
}).then((r) => r.json());
let scan;
do {
await new Promise((r) => setTimeout(r, 3000));
scan = await fetch(`https://carbonless.cc/api/v1/scans/${started.scanId}`).then((r) => r.json());
} while (scan.status === 'queued' || scan.status === 'running');
console.log(scan.rating, scan.co2.perVisit, 'g CO2e per visit');Carbon budgets in CI
The GitHub Action wraps this API into a build check: it measures a page and fails the build when your budget is exceeded. Store the key as a repository secret named CARBONLESS_API_KEY.
- uses: stfurkan/carbonless-action@v1
with:
url: ${{ steps.deploy.outputs.preview-url }}
api-key: ${{ secrets.CARBONLESS_API_KEY }}
max-co2: "0.25" # grams CO2e per visit
max-bytes: "1500000" # optional page weight budget- The scanner measures from the public internet, so point it at a URL it can reach. The strongest setup is your platform's preview deployment (Vercel, Netlify, Cloudflare Pages give every pull request a public URL), which checks exactly the code in the PR before anything reaches production. A public staging URL works the same way.
- Password-protected previews and intranet URLs cannot be measured: the crawler identifies itself and does not evade access controls.
- Preview URLs are unique per deployment, so every run is a fresh measurement. Production URLs re-measure at most once a day, like everywhere else.
Authentication
Send your key as a bearer token. Starting a scan always needs a key; requests without a valid one get a 401. Reading a scan needs no key, because every result is public.
Authorization: Bearer cbl_your_key_here- Keys are created and revoked under /keys. The plaintext is shown once at creation and never again; revocation takes effect on the next request.
- A key is a headless sign-in: it spends the same per-account quota as using the site signed in, so it is not a way around the limits.
- Keys belong on your server. Do not embed one in a browser or mobile app; anyone who can read it can spend your quota.
X-API-Key: cbl_...is accepted as an alternative header where setting Authorization is awkward.
Versioning
The version lives in the path: /api/v1. Within v1, changes are additive only: new fields may appear in responses, and existing fields keep their name, type and meaning. Anything that would change the meaning of a number you already store, such as a new emissions model or revised rating boundaries, ships as a new version path with the old one kept running and a deprecation timeline announced here.
- Each result carries
co2.model(currently"swdm-v4"), so stored figures stay attributable to the model that produced them.
The scan lifecycle
A scan moves through queued → running → either complete or failed. A real browser loads the page twice (cold, then warm to measure caching), so a typical scan finishes in under a minute.
- Pages re-measure at most once a day. Submitting a URL measured within the last day returns the existing result immediately with
"fresh": true, costs no quota, and there is no force flag: polling in a loop costs nothing and changes nothing. - Submitting a URL that is already queued or running attaches you to the in-flight scan instead of starting a second one.
failedis a real outcome, not an HTTP error: the response is still 200 witherrorexplaining why, most often a site that refuses automated visits. Failed scans of that kind are not retried; re-driving a site that said no only annoys it.- Redirects are followed and the result is filed under the final URL, reported in
measuredUrl.
Errors
Errors use conventional status codes and a JSON body with a human-readable error string plus a stable machine-readable code. Branch on the code, never on the prose: messages can be reworded, codes keep their meaning (new ones may be added).
{ "error": "That API key is not valid or has been revoked.", "code": "api_key_invalid" }400invalid_request | invalid_url | url_blocked
invalid_request: the body is not JSON with a url. invalid_url: the URL is unusable. url_blocked: it points somewhere that cannot be scanned (private addresses, blocked ports, unresolvable hosts).
401api_key_missing | api_key_invalid
The key is absent, unknown or revoked. Create or rotate keys under /keys.
404not_found
No scan exists with that id.
429rate_limited
Quota reached. The retry-after header says how many seconds to wait, and the RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers describe the window that tripped.
500internal_error
Something failed on our side. Safe to retry with backoff.
Every response carries an x-request-id header (yours is echoed back if you send a simple one). Quote it when reporting a problem and we can find the exact request in our logs.
Rate limits
- Scan submissions draw from an hourly per-account quota plus a short burst guard, shared between your keys and signed-in use of the site. Limits are generous for comparing a handful of sites and are tuned during the beta.
- Responses served from the freshness window (
"fresh": true) do not count against the quota. - On 429, wait the seconds given in
retry-afterand retry. Spreading bulk work out is kinder than hammering the hour boundary. - Reading scans has a generous per-address flood cap (well above any reasonable polling rate); a tight loop gets a 429 with the same
retry-afterhandling.
Start a scan
POST/api/v1/scans
Queues a measurement of one page, or returns the recent existing result. Requires a key.
Request body
urlstring, required
The page to measure, up to 2048 characters. The scheme is optional and https is assumed: example.com/pricing and https://example.com/pricing are equivalent. One URL is one page; a scan does not crawl the whole site.
Returns
202 Acceptedmeasurement queued
A new scan was queued. Poll the returned scanId until it completes.
200 OKfresh result exists
The page was measured within the last day; you get the existing scan with "fresh": true and its reportPath, and nothing is re-measured.
Example
curl -X POST https://carbonless.cc/api/v1/scans \
-H "Authorization: Bearer $CARBONLESS_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "example.com/pricing"}'
# 202 (queued)
{ "scanId": "9f1c..." }
# 200 (measured within the last day)
{ "scanId": "9f1c...", "fresh": true, "reportPath": "/report/example.com/pricing" }Every scan is public: submitting a URL publishes a report page for it. Do not scan pages you would not want listed.
Read a scan
GET/api/v1/scans/{id}
Returns the scan object. No authentication: everything here is already on the public report page, and polling this endpoint is how a client waits for a scan it started. Poll every few seconds, not in a tight loop.
Example
curl https://carbonless.cc/api/v1/scans/9f1c...
{
"id": "9f1c...",
"status": "complete",
"error": null,
"url": "https://example.com/pricing",
"measuredUrl": "https://example.com/pricing",
"measuredAt": "2026-08-08T09:41:22.000Z",
"rating": "B",
"co2": {
"perVisit": 0.140,
"firstVisit": 0.185,
"returnVisit": 0.008,
"unit": "grams CO2e",
"model": "swdm-v4"
},
"transfer": {
"bytes": 1245328,
"warmBytes": 53210,
"requests": 48,
"thirdPartyBytes": 402118,
"thirdPartyRequests": 12,
"domains": 7
},
"performance": { "ttfbMs": 210, "fcpMs": 890, "lcpMs": 1320, "cls": 0.01, "loadMs": 1900, "domElements": 812 },
"greenHost": false,
"cleanerThanPercent": 80.8,
"reportUrl": "https://carbonless.cc/report/example.com/pricing",
"reportPath": "/report/example.com/pricing"
}The scan object
While a scan is queued or running, only id, status, error and url are present, plus reportPath: null. The full object appears once status is complete.
idstring (uuid)
Unique identifier of this measurement.
statusenum: "queued" | "running" | "complete" | "failed"
Where the scan is in its lifecycle.
errorstring | null
Human-readable reason when status is failed, otherwise null.
urlstring
The URL as submitted, after cleanup.
measuredUrlstring
Where the browser actually landed after redirects; the report is filed under this URL.
measuredAtstring (ISO 8601)
When the measurement finished.
ratingstring: "A+" to "F"
Grade on the Sustainable Web Design digital carbon rating scale, comparable across tools that share it. Boundaries are documented in the methodology.
co2.perVisitnumber (grams)
The headline figure: grams of CO2e per average visit, weighting 75% first-time and 25% returning visitors using the cache effectiveness measured on this page.
co2.firstVisitnumber (grams)
Grams for a first-time visit with an empty cache.
co2.returnVisitnumber (grams)
Grams for a returning visit, using the measured warm-load transfer.
co2.unitstring
Always "grams CO2e".
co2.modelstring
Emissions model that produced the figures, currently "swdm-v4".
transfer.bytesnumber
Wire bytes transferred on the cold (first) visit, after compression.
transfer.warmBytesnumber | null
Wire bytes on the warm (repeat) visit with a primed cache; null when the warm load could not be measured.
transfer.requestsnumber
Requests made during the cold visit.
transfer.thirdPartyBytesnumber
Cold-visit bytes served from domains other than the page's own registrable domain.
transfer.thirdPartyRequestsnumber
Cold-visit requests to third-party domains.
transfer.domainsnumber
Distinct hostnames contacted during the cold visit.
performance.ttfbMsnumber | null
Time to first byte of the main document, in milliseconds.
performance.fcpMsnumber | null
First Contentful Paint.
performance.lcpMsnumber | null
Largest Contentful Paint.
performance.clsnumber | null
Cumulative Layout Shift.
performance.loadMsnumber | null
Time to the load event.
performance.domElementsnumber | null
Elements in the rendered DOM.
greenHostboolean | null
True when the Green Web Foundation verifies the host as running on renewable energy, false when not, null when unknown. Unknown is treated as not green in the math, which keeps estimates conservative.
cleanerThanPercentnumber (0 to 100)
Estimated share of pages on the web this page is cleaner than, interpolated over the official rating percentiles. An estimate, labeled as one.
reportUrlstring
Absolute URL of the public report page.
reportPathstring | null
Path of the public report; null until the scan completes.
Figures are estimates from a published model applied to a real measurement. The methodology states every assumption.