Rate limits
Each API key may make a set number of requests a minute - the key’s own limit, which GET /v1/account states
as limits.requestsPerMinute (60 for a key made in the
dashboard). Requests without a valid key share a smaller allowance per network address:
30 a minute.
The allowance holds a minute’s requests and refills continuously, a request at a time, rather than all at once each minute. A key allowed 60 a minute may send 60 requests at once; after that, one more becomes available every second, so it can keep sending 60 a minute indefinitely, and left alone it is full again within a minute.
Every answer - a 401 included - says where the key stands:
| Header | What it says |
|---|---|
X-RateLimit-Limit |
Requests allowed a minute, which is also the most that may be sent at once. |
X-RateLimit-Remaining |
Requests that may be sent now, without waiting. |
X-RateLimit-Reset |
Seconds until the allowance is full again; 0 when it is. |
Past the limit the answer is 429 rate_limited, with a Retry-After header saying how many whole seconds until
the next request is allowed - for a key allowed 60 a minute, usually 1 - and an upgradeUrl, the dashboard’s
Billing and credits page. Wait that long and send the request again unchanged:
#!/usr/bin/env bashset -euo pipefailAPI=https://api.speccapture.comAUTH="Authorization: Bearer $SPECCAPTURE_API_KEY"headers=$(mktemp)header() { grep -i "^$1:" "$headers" | cut -d' ' -f2- | tr -d '\r'; }
curl -sS --fail-with-body --retry 5 "$API/v1/account" \ -H "$AUTH" \ -D "$headers" -o /dev/nullecho "Limit $(header X-RateLimit-Limit) a minute, $(header X-RateLimit-Remaining) left, full again in $(header X-RateLimit-Reset) s"Save as quota.sh, set SPECCAPTURE_API_KEY, and run: bash quota.sh. Needs curl; scripts also use bash and jq.
using System.Net;using System.Text.Json;
using HttpClient http = new() { BaseAddress = new Uri("https://api.speccapture.com") };http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("SPECCAPTURE_API_KEY"));
HttpResponseMessage response = await Call(HttpMethod.Get, "/v1/account");Console.WriteLine($"Limit {response.Headers.GetValues("X-RateLimit-Limit").First()} a minute, {response.Headers.GetValues("X-RateLimit-Remaining").First()} left, full again in {response.Headers.GetValues("X-RateLimit-Reset").First()} s");
// Sends a request, waiting out a rate limit; stops with the problem's code and detail if the API refuses it.async Task<HttpResponseMessage> Call(HttpMethod method, string path) { HttpResponseMessage response; while ((response = await http.SendAsync(new HttpRequestMessage(method, path))).StatusCode == HttpStatusCode.TooManyRequests) { TimeSpan wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1); Console.Error.WriteLine($"Rate limited: waiting {wait.TotalSeconds} s"); await Task.Delay(wait); } if (!response.IsSuccessStatusCode) { JsonElement problem = await Json(response); string? detail = (problem.TryGetProperty("detail", out JsonElement text) ? text : problem.GetProperty("title")).GetString(); Console.Error.WriteLine($"{(int)response.StatusCode} {problem.GetProperty("code")}: {detail}"); Environment.Exit(1); } return response;}
// Reads an answer as JSON.static async Task<JsonElement> Json(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;Save as quota.cs, set SPECCAPTURE_API_KEY, and run: dotnet run quota.cs. Needs .NET 10 or later.
import osimport sysimport time
import requests
API = "https://api.speccapture.com"session = requests.Session()session.headers["Authorization"] = f"Bearer {os.environ['SPECCAPTURE_API_KEY']}"
def call(method, path, **kwargs): """Send a request, waiting out a rate limit; stop with the problem's code if the API refuses it.""" while True: response = session.request(method, API + path, **kwargs) if response.status_code != 429: break wait = int(response.headers.get("Retry-After", "1")) print(f"Rate limited: waiting {wait} s", file=sys.stderr) time.sleep(wait) if not response.ok: problem = response.json() sys.exit(f"{response.status_code} {problem['code']}: {problem.get('detail', problem['title'])}") return response
response = call("GET", "/v1/account")print(f"Limit {response.headers['X-RateLimit-Limit']} a minute, {response.headers['X-RateLimit-Remaining']} left, full again in {response.headers['X-RateLimit-Reset']} s")Save as quota.py, set SPECCAPTURE_API_KEY, and run: python quota.py. Needs Python 3.9+ and requests (pip install requests).
import { setTimeout as sleep } from "node:timers/promises";
const API = "https://api.speccapture.com";const headers = { Authorization: `Bearer ${process.env.SPECCAPTURE_API_KEY}` };
const response = await call("GET", "/v1/account");console.log(`Limit ${response.headers.get("X-RateLimit-Limit")} a minute, ${response.headers.get("X-RateLimit-Remaining")} left, full again in ${response.headers.get("X-RateLimit-Reset")} s`);
// Sends a request, waiting out a rate limit; stops with the problem's code and detail if the API refuses it.async function call(method, path, init = {}) { let response; while ((response = await fetch(API + path, { ...init, method, headers: { ...headers, ...init.headers } })).status === 429) { const wait = Number(response.headers.get("Retry-After") ?? 1); console.error(`Rate limited: waiting ${wait} s`); await sleep(wait * 1000); } if (!response.ok) { const problem = await response.json(); console.error(`${response.status} ${problem.code}: ${problem.detail ?? problem.title}`); process.exit(1); } return response;}Save as quota.mjs, set SPECCAPTURE_API_KEY, and run: node quota.mjs. Needs Node.js 20 or later.
To stay under the limit, ask for a job every few seconds rather than in a tight loop, read the sheets of a
finished job once and keep what you read, or let a webhook tell you when a job finishes.
Separately, a network address that presents too many keys SpecCapture does not know -
60 in a minute - is answered 429 rate_limited too, so a
mistyped key in a retry loop slows down rather than hammering on. The API reference and the health check are
outside every limit.