Unknown vocabulary
Teams guess query strings instead of browsing the catalog’s real tag set.
Use case · Tag curation
Editors and product teams need theme-first discovery—not only free-text search.
Use GET https://epochalstock.com/api/v1/tags for normalized tags with
image_count, GET /tags/{tag} for a single theme, then filter the
catalog with GET /images?tags=….
Keyword boxes alone do not support editorial workflows. Curators want “show me everything tagged forest,” ranked by how much inventory exists, with stable filters they can pin into home screens and seasonal campaigns.
Teams guess query strings instead of browsing the catalog’s real tag set.
Without counts, UIs promote filters that return zero or near-zero results.
Ad-hoc client taxonomies diverge from the normalized tags on each image.
“Explore by theme” needs list + detail tag APIs, not only q= search.
Treat tags as a first-class discovery index. List them with counts, optionally drill into one tag, then load images that carry those tags.
| Goal | Endpoint | Notes |
|---|---|---|
| Browse / search tag vocabulary | GET /tags |
q, sort=name|count, order, limit, cursor |
| Confirm one theme | GET /tags/{tag} |
Normalized tag, exact image_count, filtered image URL hint |
| List assets for a theme | GET /images?tags=… |
Optional tag_mode=all|any, sort, limit, cursor |
| Catalog scale context | GET /stats |
total_images, total_tags, catalog_built_at |
Complete parameter tables and live catalog tools: Tags & catalog statistics. Image filter details: Search & list images.
Top tags by image_count (limit 12), loaded server-side via the docs site’s API client when a key
is configured. Values are escaped for safe HTML rendering.
Live from API
Popular tags by image count
Use these names exactly (normalized) when calling
GET /images?tags=…. Prefer showing counts in chip UIs so curators avoid empty themes.
Theme discovery → asset grid
GET /tags/{tag} (spaces and special characters)tag_mode=all (intersection) vs any (union)q when editors refine a theme with free textGET /tags?sort=count&order=desc&limit=50
for “Popular themes.”
GET /tags?q=fore&sort=name&order=asc
as the curator types.
GET /tags/{url_encoded_tag}.
GET /images?tags=forest&sort=newest&limit=30
(add more tags and tag_mode as needed).
next_cursor; store selected IDs for
batch rehydrate
or downloads.
GET /tags
Params: q, sort (name|count),
order (asc|desc), limit, cursor.
Items: tag + image_count.
GET /tags/{tag}One normalized tag, exact count, and the corresponding filtered image list URL for follow-up calls.
GET /images?tags=
Up to 20 comma-separated tags; tag_mode=all (default) or any;
full search sort/limit/cursor support.
GET /statsShow total inventory beside your curation chrome so editors know catalog scale at a glance.
curl -sS "https://epochalstock.com/api/v1/tags?sort=count&order=desc&limit=50" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json"
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const params = new URLSearchParams({
sort: "count",
order: "desc",
limit: "50",
});
const res = await fetch(`${base}/tags?${params}`, {
headers: {
Authorization: `Bearer ${process.env.EPOCHAL_API_KEY}`,
Accept: "application/json",
},
});
if (!res.ok) throw new Error(await res.text());
const { data } = await res.json();
// data.tags: [{ tag, image_count }, …]
for (const row of data.tags) {
console.log(row.tag, row.image_count);
}
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$query = http_build_query([
'sort' => 'count',
'order' => 'desc',
'limit' => 50,
]);
$ch = curl_init($base . '/tags?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EPOCHAL_API_KEY'),
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException((string) $body);
}
$tags = json_decode($body, true, 512, JSON_THROW_ON_ERROR)['data']['tags'];
// each: tag, image_count
import os
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1")
r = requests.get(
f"{base}/tags",
params={"sort": "count", "order": "desc", "limit": 50},
headers={
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
},
timeout=20,
)
r.raise_for_status()
for row in r.json()["data"]["tags"]:
print(row["tag"], row["image_count"])
# One tag (URL-encode the path segment)
curl -sS "https://epochalstock.com/api/v1/tags/golden%20hour" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
# Images carrying that theme (plus another tag, both required)
curl -sS "https://epochalstock.com/api/v1/images?tags=golden%20hour,nature&tag_mode=all&sort=newest&limit=30" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json"
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const key = process.env.EPOCHAL_API_KEY;
const headers = {
Authorization: `Bearer ${key}`,
Accept: "application/json",
};
async function curateTheme(tag, extraTags = []) {
const tagRes = await fetch(
`${base}/tags/${encodeURIComponent(tag)}`,
{ headers }
);
if (tagRes.status === 404) return { tag, imageCount: 0, images: [] };
if (!tagRes.ok) throw new Error(await tagRes.text());
const tagData = (await tagRes.json()).data;
const tags = [tag, ...extraTags].join(",");
const params = new URLSearchParams({
tags,
tag_mode: "all",
sort: "newest",
limit: "30",
});
const imgRes = await fetch(`${base}/images?${params}`, { headers });
if (!imgRes.ok) throw new Error(await imgRes.text());
const page = (await imgRes.json()).data;
return {
tag: tagData.tag,
imageCount: tagData.image_count,
images: page.images,
nextCursor: page.next_cursor ?? null,
total: page.total,
};
}
function curate_theme(string $tag, array $extraTags = []): array
{
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$headers = [
'Authorization: Bearer ' . getenv('EPOCHAL_API_KEY'),
'Accept: application/json',
];
$ch = curl_init($base . '/tags/' . rawurlencode($tag));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 404) {
return ['tag' => $tag, 'image_count' => 0, 'images' => [], 'next_cursor' => null];
}
if ($status !== 200) {
throw new RuntimeException((string) $body);
}
$tagData = json_decode($body, true, 512, JSON_THROW_ON_ERROR)['data'];
$params = http_build_query([
'tags' => implode(',', array_merge([$tag], $extraTags)),
'tag_mode' => 'all',
'sort' => 'newest',
'limit' => 30,
]);
$ch = curl_init($base . '/images?' . $params);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException((string) $body);
}
$page = json_decode($body, true, 512, JSON_THROW_ON_ERROR)['data'];
return [
'tag' => $tagData['tag'],
'image_count' => $tagData['image_count'],
'images' => $page['images'],
'next_cursor' => $page['next_cursor'] ?? null,
'total' => $page['total'] ?? null,
];
}
import os
from urllib.parse import quote
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1")
headers = {
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
}
def curate_theme(tag: str, extra_tags=None):
extra_tags = extra_tags or []
tr = requests.get(
f"{base}/tags/{quote(tag, safe='')}",
headers=headers,
timeout=20,
)
if tr.status_code == 404:
return {"tag": tag, "image_count": 0, "images": [], "next_cursor": None}
tr.raise_for_status()
tag_data = tr.json()["data"]
r = requests.get(
f"{base}/images",
params={
"tags": ",".join([tag, *extra_tags]),
"tag_mode": "all",
"sort": "newest",
"limit": 30,
},
headers=headers,
timeout=20,
)
r.raise_for_status()
page = r.json()["data"]
return {
"tag": tag_data["tag"],
"image_count": tag_data["image_count"],
"images": page["images"],
"next_cursor": page.get("next_cursor"),
"total": page.get("total"),
}
qcurl -sS "https://epochalstock.com/api/v1/tags?q=fore&sort=name&order=asc&limit=20" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
async function tagTypeahead(prefix) {
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const params = new URLSearchParams({
q: prefix,
sort: "name",
order: "asc",
limit: "20",
});
const res = await fetch(`${base}/tags?${params}`, {
headers: {
Authorization: `Bearer ${process.env.EPOCHAL_API_KEY}`,
Accept: "application/json",
},
});
if (!res.ok) throw new Error(await res.text());
return (await res.json()).data.tags;
}
function tag_typeahead(string $prefix): array
{
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$query = http_build_query([
'q' => $prefix,
'sort' => 'name',
'order' => 'asc',
'limit' => 20,
]);
$ch = curl_init($base . '/tags?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EPOCHAL_API_KEY'),
'Accept: application/json',
],
CURLOPT_TIMEOUT => 15,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException((string) $body);
}
return json_decode($body, true, 512, JSON_THROW_ON_ERROR)['data']['tags'];
}
import os
import requests
def tag_typeahead(prefix: str):
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1")
r = requests.get(
f"{base}/tags",
params={"q": prefix, "sort": "name", "order": "asc", "limit": 20},
headers={
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
},
timeout=15,
)
r.raise_for_status()
return r.json()["data"]["tags"]
Every authenticated tags or images call counts toward the shared
30 requests per minute per account budget. Popular-tag rails should be cached on your
backend (for example 5–15 minutes) so every editor page view does not re-hit GET /tags.
Debounce typeahead (q) so each keystroke does not become a full rate unit. Coalesce
identical in-flight requests. On HTTP 429, honor Retry-After.
sort=count for home rails; sort=name for A–Z browserscursor values—never invent themtag_not_found (HTTP 404) on GET /tags/{tag}Full limits and errors: Rate limits & errors.
Live catalog totals, full tag chart, and parameter reference for /tags and /stats.
Combine tag chips with free-text q and cursor pagination.
After curators multi-select, rehydrate IDs with POST /images/batch.
Complete filter, sort, and pagination docs for image listing.