Shared budget
Every live key on the account draws from the same 30 req/min pool.
Developer Image API · v1
Authenticated catalog traffic is limited to 30 requests per minute per account
on the paid plan and 2 per minute on the free trial (which also has a lifetime
budget of 2,000 image requests). All named API keys share that budget. Responses
include rate-limit headers; over-limit calls return HTTP 429 with
rate_limit_exceeded. Errors use a consistent JSON shape so your server can branch on
error.code without scraping free-form messages.
Plan capacity is fixed for v1: the $50 USD/month subscription
(first month $40 with the 20% upgrade discount) unlocks the full catalog, up to 20 named keys,
unlimited serial original downloads, and a shared 30 authenticated requests per minute
ceiling. The free trial runs at 2 requests/minute with a lifetime budget of
2,000 image requests (responses carry X-Trial-Requests-Remaining).
Creating more keys does not multiply the rate limit or the concurrent-download lease.
Every live key on the account draws from the same 30 req/min pool.
X-RateLimit-Limit, Remaining, and Reset on responses.
error.code, message, optional details, and request_id.
Backoff only for 429, 502, and 503—not other 4xx.
The limit is 30 authenticated API requests per minute per account. Catalog
endpoints that require a Bearer key (search, detail, batch, tags, stats, minting download tokens,
and similar) consume this budget. Binary original transfer via a signed
download_url does not consume an extra catalog request, but it is
governed by the one-at-a-time concurrency lease described on
Downloads.
Example minute: 6 used · 24 remaining · limit 30
Gold cells illustrate used requests in the current window; remaining cells are still available
before X-RateLimit-Reset.
429, a Retry-After delay, and rate_limit_exceeded.
Successful and many error responses include the current budget state. Use them to throttle
proactively instead of waiting for 429.
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 24
X-RateLimit-Reset: 1784498400
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Maximum authenticated requests allowed in the current window (30). |
X-RateLimit-Remaining |
Requests left before the limit resets. |
X-RateLimit-Reset |
Unix timestamp when the window resets (UTC-based interpretation on the client). |
Retry-After |
Present on 429 responses—seconds (or delay guidance) before retrying. |
X-Request-ID |
Correlation id for support and logs on every response. |
On 429: body code is rate_limit_exceeded. Honor
Retry-After (or wait until X-RateLimit-Reset) with jitter. Do not
spin tight loops that burn remaining budget as soon as the window opens.
POST https://epochalstock.com/api/v1/images/batch accepts 1–100 unique image IDs
and counts as one API request. Prefer batch when a design workflow already knows
many IDs instead of issuing one GET /images/{id} per asset.
Same 50 IDs · different cost
Details and examples: Image detail & batch.
Error responses use a consistent envelope. Branch on error.code for machine-readable
handling; show or log message for humans; always retain request_id.
{
"error": {
"code": "invalid_parameter",
"message": "q must not exceed 200 characters.",
"details": { "parameter": "q" },
"request_id": "req_..."
}
}
| Field | Description |
|---|---|
error.code |
Stable machine code (see table below). |
error.message |
Human-readable explanation; may change wording over time. |
error.details |
Optional structured context (parameter names, etc.). |
error.request_id |
Same correlation id as X-Request-ID when present. |
Successful JSON uses data plus meta.request_id. Do not assume error
bodies appear on every non-JSON response (for example some binary download failures).
Full catalog of documented HTTP statuses and codes for the Developer Image API v1.
| HTTP | Code(s) | Meaning |
|---|---|---|
400 |
invalid_json,
invalid_cursor,
query_key_rejected,
subscription_id_required,
invalid_webhook
|
Request syntax or parameter is invalid (bad JSON, cursor, query-string key, or webhook). |
401 |
invalid_api_key,
invalid_download_token,
invalid_credentials,
unauthenticated,
invalid_webhook_signature
|
Authentication, session, download token, or webhook signature validation failed. |
402 |
subscription_required,
subscription_past_due
|
The paid plan is not active or payment is past due. |
402 |
trial_limit_reached,
trial_expired
|
Permanent until subscribed — do not retry. The trial's 2,000-request budget is used up or the trial month is over; details carries upgrade_url, a one-click upgrade_link, and the 20%-off first-month offer. |
403 |
invalid_csrf,
insufficient_scope,
cors_not_supported,
subscription_mismatch
|
Security policy rejected the request (CSRF, scope, browser/CORS key use, or account mismatch). |
404 |
not_found,
image_not_found,
tag_not_found,
key_not_found,
subscription_not_found,
original_unavailable
|
Resource does not exist or original cannot be served. |
409 |
download_already_active,
key_limit_reached,
subscription_exists,
email_exists,
trial_already_used
|
Resource state conflicts with the request (concurrent download, key cap, duplicate email, one trial per email, etc.). |
410 |
upgrade_link_expired |
A signed upgrade link is invalid or expired; request a new one on the upgrade page. |
413 |
payload_too_large |
The request or webhook body exceeds the size limit. |
422 |
invalid_parameter,
weak_password,
invalid_email,
invalid_name,
invalid_app_name,
invalid_key_name
|
Valid JSON contains invalid field values. |
429 |
rate_limit_exceeded,
login_rate_limited,
too_many_requests
|
Too many requests; retry after the returned delay. |
500 |
internal_error |
Unexpected server failure; report Request-ID to support. |
502 |
paypal_unavailable |
PayPal call or verification failed (billing flows). |
503 |
catalog_unavailable,
billing_unavailable,
original_unavailable
|
Required backend, index, file, or configuration is temporarily unavailable. |
Tip: Map codes to product UX carefully.
402 should route operators to billing;
409 download_already_active should pause export workers;
query_key_rejected means fix the client, not retry.
Retries are safe only for a small set of statuses. Blindly retrying validation or auth failures will not help and can worsen rate-limit pressure.
Use bounded exponential backoff with jitter. Prefer Retry-After on
429 when present. Cap total attempts.
400, 401, 402, 403, 404,
422 usually need a code or account fix—not another identical request.
Wait for the in-flight original to finish, then retry download minting—serialize workers rather than parallel storms.
Log Request-ID and timestamp. A single cautious retry may be acceptable; do not hammer.
min(cap, base * 2^attempt) + random_jitter.Retry-After as a minimum sleep when the server provides it.GETs are safer to retry than duplicate side effects; still respect rate limits.
Minimal patterns for retrying transient failures (429, 502,
503) with exponential backoff and jitter. Load secrets from the environment only.
async function fetchWithBackoff(url, options = {}, maxAttempts = 5) {
let attempt = 0;
for (;;) {
const res = await fetch(url, options);
if (res.ok) return res;
const retryable = [429, 502, 503].includes(res.status);
if (!retryable || attempt >= maxAttempts - 1) {
const body = await res.text();
throw new Error(`HTTP ${res.status} id=${res.headers.get("x-request-id")} ${body}`);
}
const retryAfter = Number(res.headers.get("retry-after"));
const base = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(30_000, 500 * 2 ** attempt);
const jitter = Math.floor(Math.random() * 250);
await new Promise((r) => setTimeout(r, base + jitter));
attempt += 1;
}
}
// Example: catalog search
const res = await fetchWithBackoff(
"https://epochalstock.com/api/v1/images?limit=30",
{
headers: {
Authorization: `Bearer ${process.env.EPOCHAL_API_KEY}`,
Accept: "application/json",
},
}
);
const json = await res.json();
import os
import random
import time
import requests
RETRYABLE = {429, 502, 503}
def fetch_with_backoff(method, url, *, max_attempts=5, **kwargs):
attempt = 0
while True:
r = requests.request(method, url, timeout=20, **kwargs)
if r.ok:
return r
if r.status_code not in RETRYABLE or attempt >= max_attempts - 1:
rid = r.headers.get("X-Request-ID")
raise RuntimeError(f"HTTP {r.status_code} request_id={rid} body={r.text}")
retry_after = r.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
delay = float(retry_after)
else:
delay = min(30.0, 0.5 * (2 ** attempt))
delay += random.uniform(0, 0.25)
time.sleep(delay)
attempt += 1
r = fetch_with_backoff(
"GET",
"https://epochalstock.com/api/v1/images",
params={"limit": 30},
headers={
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
},
)
print(r.headers.get("X-RateLimit-Remaining"), r.json())
function fetch_with_backoff(string $url, array $headers, int $maxAttempts = 5): string
{
$attempt = 0;
while (true) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20,
CURLOPT_HEADER => true,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headerBlob = substr((string) $raw, 0, $headerSize);
$body = substr((string) $raw, $headerSize);
if ($status >= 200 && $status < 300) {
return $body;
}
$retryable = in_array($status, [429, 502, 503], true);
if (!$retryable || $attempt >= $maxAttempts - 1) {
throw new RuntimeException("HTTP {$status}: {$body}");
}
$retryAfter = 0.0;
if (preg_match('/^Retry-After:\s*(\d+)/mi', $headerBlob, $m)) {
$retryAfter = (float) $m[1];
}
$base = $retryAfter > 0 ? $retryAfter : min(30.0, 0.5 * (2 ** $attempt));
$jitter = mt_rand(0, 250) / 1000;
usleep((int) (($base + $jitter) * 1_000_000));
$attempt++;
}
}
$headers = [
'Authorization: Bearer ' . getenv('EPOCHAL_API_KEY'),
'Accept: application/json',
];
$json = fetch_with_backoff(
'https://epochalstock.com/api/v1/images?limit=30',
$headers
);
X-Request-ID matters
Every API response includes X-Request-ID. Error JSON usually mirrors it as
error.request_id; successful bodies often expose meta.request_id.
This is the primary correlation token for support, incident review, and multi-service logs.
429 spikes across keys.Do not paste secrets into tickets. A Request-ID plus approximate timestamp is enough for server-side lookup. Rotate any key that may have been exposed in logs or chat.
Two-step originals, concurrency lease, and token security.
Bearer headers, key lifecycle, and auth-specific errors.
Cut request usage with up to 100 IDs per batch call.
Subscription states that surface as 402 errors.