Inventory blindness
Marketing and ops want total_images, total_tags, and when the
index was built—without scraping search pages.
Use case · Catalog ops
When you ship a design plugin, CMS illustrator, or agency library on the Developer Image API,
someone still needs a clear answer to: Is the catalog up? How large is inventory? Are we
burning the shared rate budget? This page shows how to build a lightweight ops view from
documented v1 endpoints only—primarily GET /stats, GET /health, and
response headers—while keeping the API key on your backend.
Product and support teams feel catalog issues as “search is empty,” “export is stuck,” or “everything is slow,” but the underlying causes differ: subscription state, rate-limit exhaustion, a degraded service, or a real inventory change after an index rebuild. Without a small internal dashboard, engineers open production logs under pressure and may even be tempted to call the API from a browser with a live key.
Marketing and ops want total_images, total_tags, and when the
index was built—without scraping search pages.
A failed search might be DNS, TLS, or service degradation—or a bad key.
GET /health separates “is the service up?” from “is our credential valid?”
All named keys on the account share 30 authenticated requests per minute. Interactive search plus batch jobs plus export minting can collide unless you watch headers.
Ops tooling must stick to documented v1 surfaces. Do not assume extra metrics APIs beyond what the catalog and management docs describe.
Goal of this use case: A server-side ops strip (or internal admin page) that
periodically reads health and stats, samples rate-limit headers from real traffic, optionally
tracks tag totals as a coarse inventory signal, and alerts humans—without embedding
EPOCHAL_API_KEY in browsers or third-party status widgets.
Same rule as every other integration: the application backend holds the API key.
Your internal dashboard is just another trusted client of your backend (SSO, VPN, or admin
session). The backend calls https://epochalstock.com/api/v1.
Internal UI → your backend → health / stats / headers
/health more aggressively if needed—it does not require a Bearer token./stats on a modest cadence (for example every few minutes) and cache the result so monitoring does not burn the 30 req/min budget.
Illustrative HTML-only mock using existing site components. Wire the numbers from your backend
cache of GET /stats, last GET /health probe, and recent
X-RateLimit-Remaining samples—not from browser-side Epochal calls.
Epochal catalog · ops strip (mock data)
Last probe GET /health → HTTP 200. On 503, treat as degraded and pause
non-critical batch jobs while you verify status.
Limit 30 · Remaining 24 · Reset unix 1784498400.
Source: headers on a recent authenticated response—not a separate metrics API.
Compare catalog_built_at to your last snapshot. A sudden drop in
total_images deserves a human check before you page customers.
Periodic GET /tags?sort=count&order=desc&limit=10 can show whether
top-tag distribution still looks sane (see
Tags & stats).
Current minute (example): 6 used · 24 remaining · limit 30
Gold cells = authenticated requests already used in the window. Drive this UI from
X-RateLimit-Remaining and X-RateLimit-Limit on real traffic.
Mock only. Figures above are illustrative. Production values come from your
server’s last successful GET /stats and live response headers. Do not hardcode
inventory numbers into customer-facing marketing without refreshing them from the API.
GET /stats — catalog inventoryAuthenticated catalog endpoint. Returns three documented fields for inventory visibility:
| Field | Meaning for ops |
|---|---|
total_images |
How many images the catalog currently exposes to the API. |
total_tags |
How many normalized tags exist—useful as a coarse taxonomy health signal. |
catalog_built_at |
When the catalog index was built; watch for unexpected staleness or jumps after rebuilds. |
GET https://epochalstock.com/api/v1/stats
Authorization: Bearer $EPOCHAL_API_KEY
Accept: application/json
# Counts toward the shared 30 req/min account budget
# Capture X-RateLimit-* and X-Request-ID on the response
/health.Live docs-site snapshot and field examples: Tags & catalog statistics.
GET /health — service availability
GET /health does not require a Bearer token. Use it to verify DNS,
TLS, and service availability before debugging authentication or empty search results.
OpenAPI documents HTTP 200 as healthy and 503 as degraded.
GET https://epochalstock.com/api/v1/health
# No Authorization header required
# 200 → healthy
# 503 → degraded / unavailable
Look at API key validity, subscription state (402), rate limits
(429), or request shape—not raw connectivity.
Back off non-critical jobs, surface a maintenance banner in admin UI, and keep capturing
X-Request-ID from any authenticated errors you still receive.
Health is cheap relative to authenticated catalog calls, but still use sensible intervals (for example 30–60s) from a single monitor—not from every browser tab.
Getting started also recommends health before first search: Getting started.
Authenticated catalog traffic is limited to 30 requests per minute per account. Creating more keys does not multiply the budget. Responses include:
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 24
X-RateLimit-Reset: 1784498400
| Header | Ops use |
|---|---|
X-RateLimit-Limit |
Expected ceiling (30). Graph as a constant reference line. |
X-RateLimit-Remaining |
Throttle workers when low; show the dashboard meter. |
X-RateLimit-Reset |
Unix time when the window resets—schedule burst work after this. |
Retry-After |
On HTTP 429 / rate_limit_exceeded, wait at least this long before retrying. |
X-Request-ID |
Correlation id for support tickets and structured logs. |
Practical pattern: Middleware on your Epochal HTTP client records
Remaining and Reset into your metrics system on every catalog call
you already make (search, batch, stats, download mint). That yields a live budget graph without
inventing a dedicated “rate limit status” API.
Deep dive on 429 handling and error codes: Rate limits & errors.
GET /stats already exposes total_tags. For a richer ops signal, periodically
fetch a small page of tags with image counts:
GET https://epochalstock.com/api/v1/tags?sort=count&order=desc&limit=10
Authorization: Bearer $EPOCHAL_API_KEY
Accept: application/json
# Each item: { "tag": "…", "image_count": N }
total_tags and top-N image_count values in your own time-series store if you need trends—the API does not return historical series.GET /tags/{url_encoded_tag} when investigating a specific facet.Interactive tag list and live charts on this docs site: Tags & catalog statistics.
Label clearly: GET /usage is a management endpoint for
daily per-endpoint request/error/download totals. It is not part of the
catalog surface used with a Bearer API key for search and downloads. It requires the
server-side management session (portal cookie + CSRF rules for state-changing sibling calls),
not a public browser integration.
If your ops team needs billing-period request breakdowns beyond the live rate-limit headers,
your backend can proxy management usage as documented on
Security & billing · Account & billing API.
Do not confuse /usage with /stats:
| Endpoint | Auth | Answers |
|---|---|---|
GET /stats |
Bearer API key | Catalog inventory snapshot (total_images, total_tags, catalog_built_at). |
GET /health |
None | Service health (200 / 503). |
GET /usage |
Management session | Daily per-endpoint request/error/download totals (account management). |
Backend-only probes: health (no key), stats (Bearer key), and recording rate-limit headers.
Base URL: https://epochalstock.com/api/v1.
API_BASE="${EPOCHAL_API_BASE:-https://epochalstock.com/api/v1}"
# 1) Health — no API key
curl -sS -i "$API_BASE/health"
# 2) Stats — Bearer required; note rate-limit headers
curl -sS -D - "$API_BASE/stats" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json" \
-o stats.json
# Inspect X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Request-ID
# Parse stats.json → data.total_images, data.total_tags, data.catalog_built_at
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const apiKey = process.env.EPOCHAL_API_KEY;
async function probeHealth() {
const res = await fetch(\`${base}/health\`);
return { status: res.status, ok: res.status === 200 };
}
async function probeStats() {
if (!apiKey) throw new Error("Set EPOCHAL_API_KEY");
const res = await fetch(\`${base}/stats\`, {
headers: {
Authorization: \`Bearer ${apiKey}\`,
Accept: "application/json",
},
});
const headers = {
limit: res.headers.get("X-RateLimit-Limit"),
remaining: res.headers.get("X-RateLimit-Remaining"),
reset: res.headers.get("X-RateLimit-Reset"),
requestId: res.headers.get("X-Request-ID"),
};
if (res.status === 429) {
return { status: 429, retryAfter: res.headers.get("Retry-After"), headers };
}
if (!res.ok) throw new Error(\`stats ${res.status}: ${await res.text()}\`);
const body = await res.json();
return {
status: res.status,
headers,
total_images: body.data.total_images,
total_tags: body.data.total_tags,
catalog_built_at: body.data.catalog_built_at,
};
}
// Cache these results in your process; serve ops UI from cache
const health = await probeHealth();
const stats = await probeStats();
console.log({ health, stats });
<?php
declare(strict_types=1);
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$apiKey = getenv('EPOCHAL_API_KEY');
function probe_health(string $base): array
{
$ch = curl_init($base . '/health');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'ok' => $status === 200];
}
function probe_stats(string $base, string $apiKey): array
{
$ch = curl_init($base . '/stats');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$headerBlob = substr((string) $raw, 0, $headerSize);
$bodyRaw = substr((string) $raw, $headerSize);
// Parse X-RateLimit-* from $headerBlob for your metrics sink
if ($status !== 200) {
throw new RuntimeException('stats failed: ' . $status . ' ' . $bodyRaw);
}
$data = json_decode($bodyRaw, true, 512, JSON_THROW_ON_ERROR)['data'];
return [
'total_images' => $data['total_images'],
'total_tags' => $data['total_tags'],
'catalog_built_at' => $data['catalog_built_at'],
'raw_headers' => $headerBlob,
];
}
$health = probe_health($base);
$stats = $apiKey ? probe_stats($base, $apiKey) : null;
// Persist to cache; ops UI reads cache only
import os
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
api_key = os.environ.get("EPOCHAL_API_KEY")
def probe_health() -> dict:
r = requests.get(f"{base}/health", timeout=10)
return {"status": r.status_code, "ok": r.status_code == 200}
def probe_stats() -> dict:
if not api_key:
raise RuntimeError("Set EPOCHAL_API_KEY")
r = requests.get(
f"{base}/stats",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
timeout=20,
)
headers = {
"limit": r.headers.get("X-RateLimit-Limit"),
"remaining": r.headers.get("X-RateLimit-Remaining"),
"reset": r.headers.get("X-RateLimit-Reset"),
"request_id": r.headers.get("X-Request-ID"),
"retry_after": r.headers.get("Retry-After"),
}
if r.status_code == 429:
return {"status": 429, "headers": headers}
r.raise_for_status()
data = r.json()["data"]
return {
"status": r.status_code,
"headers": headers,
"total_images": data["total_images"],
"total_tags": data["total_tags"],
"catalog_built_at": data["catalog_built_at"],
}
print(probe_health())
print(probe_stats())
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
func probeHealth(base string) (int, error) {
client := &http.Client{Timeout: 10 * time.Second}
res, err := client.Get(base + "/health")
if err != nil {
return 0, err
}
defer res.Body.Close()
io.Copy(io.Discard, res.Body)
return res.StatusCode, nil
}
func probeStats(base, apiKey string) error {
req, _ := http.NewRequest(http.MethodGet, base+"/stats", nil)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
fmt.Println("X-RateLimit-Limit", res.Header.Get("X-RateLimit-Limit"))
fmt.Println("X-RateLimit-Remaining", res.Header.Get("X-RateLimit-Remaining"))
fmt.Println("X-RateLimit-Reset", res.Header.Get("X-RateLimit-Reset"))
fmt.Println("X-Request-ID", res.Header.Get("X-Request-ID"))
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return fmt.Errorf("stats %d: %s", res.StatusCode, body)
}
var payload struct {
Data struct {
TotalImages int `json:"total_images"`
TotalTags int `json:"total_tags"`
CatalogBuiltAt string `json:"catalog_built_at"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return err
}
fmt.Println(payload.Data.TotalImages, payload.Data.TotalTags, payload.Data.CatalogBuiltAt)
return nil
}
func main() {
base := os.Getenv("EPOCHAL_API_BASE")
if base == "" {
base = "https://epochalstock.com/api/v1"
}
if status, err := probeHealth(base); err != nil {
panic(err)
} else {
fmt.Println("health", status)
}
if err := probeStats(base, os.Getenv("EPOCHAL_API_KEY")); err != nil {
panic(err)
}
}
using System.Net.Http.Headers;
using System.Text.Json;
var baseUrl = (Environment.GetEnvironmentVariable("EPOCHAL_API_BASE")
?? "https://epochalstock.com/api/v1").TrimEnd('/');
var apiKey = Environment.GetEnvironmentVariable("EPOCHAL_API_KEY");
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
// Health — no API key
using (var healthRes = await client.GetAsync(baseUrl + "/health"))
{
Console.WriteLine("health " + (int)healthRes.StatusCode);
}
// Stats — Bearer required
if (string.IsNullOrEmpty(apiKey)) throw new InvalidOperationException("Set EPOCHAL_API_KEY");
using var statsReq = new HttpRequestMessage(HttpMethod.Get, baseUrl + "/stats");
statsReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
statsReq.Headers.Accept.ParseAdd("application/json");
using var statsRes = await client.SendAsync(statsReq);
Console.WriteLine("X-RateLimit-Remaining: " +
(statsRes.Headers.TryGetValues("X-RateLimit-Remaining", out var rem)
? string.Join(",", rem) : "(none)"));
statsRes.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await statsRes.Content.ReadAsStringAsync());
var data = doc.RootElement.GetProperty("data");
Console.WriteLine(data.GetProperty("total_images"));
Console.WriteLine(data.GetProperty("total_tags"));
Console.WriteLine(data.GetProperty("catalog_built_at"));
require "json"
require "net/http"
require "uri"
base = ENV.fetch("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").sub(%r{/+\z}, "")
api_key = ENV["EPOCHAL_API_KEY"]
# Health — no API key
health_uri = URI("#{base}/health")
health = Net::HTTP.start(health_uri.host, health_uri.port, use_ssl: true) do |http|
http.get(health_uri.request_uri)
end
puts "health #{health.code}"
# Stats — Bearer required
raise "Set EPOCHAL_API_KEY" if api_key.nil? || api_key.empty?
stats_uri = URI("#{base}/stats")
req = Net::HTTP::Get.new(stats_uri)
req["Authorization"] = "Bearer #{api_key}"
req["Accept"] = "application/json"
stats = Net::HTTP.start(stats_uri.host, stats_uri.port, use_ssl: true) do |http|
http.request(req)
end
puts "X-RateLimit-Remaining: #{stats["X-RateLimit-Remaining"]}"
raise "stats failed: #{stats.code}" unless stats.code.to_i == 200
data = JSON.parse(stats.body).fetch("data")
puts [data["total_images"], data["total_tags"], data["catalog_built_at"]].join(" ")
Additional stats/tags samples: Tags & stats examples. Full multi-language catalog: Code examples.
Triage order when something feels wrong
GET /health. If 503, assume platform degradation;
pause non-essential batch/export load.
X-RateLimit-Remaining and any 429s with
Retry-After. Slow workers; prefer batch metadata over chatty single-id loops.
GET /stats. Confirm total_images /
catalog_built_at still look plausible versus yesterday’s cached snapshot.
409 download_already_active
or token expiry, not empty inventory. See
Safe original export.
error.code, and
X-Request-ID—never API keys or full signed download URLs.
Cache stats, watch headers on real traffic, keep keys server-side, alert on sustained failure.
Poll stats every second, embed keys in statuspage widgets, or invent undocumented monitoring endpoints.
Ops probes share the same 30 req/min pool as product traffic—design them to be quiet.
Binary original GET does not consume an extra catalog request but holds the one-at-a-time lease.
Live GET /tags and GET /stats reference with examples.
30 req/min shared budget, headers, 429 strategy, and full error table.
Management session, GET /usage, and key lifecycle.
Serial download queue and 409 download_already_active handling.