Unclear licensing
Scraped or mixed sources create legal risk for agencies and SaaS vendors shipping production art.
Use case · Design asset search
Design plugins, web editors, and agency tools need search that is fast, filterable, and legally clear.
Epochal Stock’s GET https://epochalstock.com/api/v1/images endpoint is the primary discovery
surface: free-text q, tag filters, stable sort, and opaque cursor pagination—backed by the
full catalog on the single $50/month plan.
Product teams building design software repeatedly hit the same wall: users expect a stock browser inside the canvas, but wiring “search” is harder than it looks.
Scraped or mixed sources create legal risk for agencies and SaaS vendors shipping production art.
Keyword-only UIs without tags, sort control, or reliable paging feel broken after the first page.
Browser-side API keys in plugins get extracted immediately—and Epochal rejects CORS catalog access for secret keys.
Naïve “load everything” loops burn the shared 30 req/min account limit before designers finish browsing.
Out of scope for this use case: putting a secret Bearer key in client-side JavaScript,
mobile app binaries, or public plugin packages. Catalog calls must run on a server (or trusted plugin backend)
that holds EPOCHAL_API_KEY. See
Authentication.
GET /images
Implement search as a thin backend proxy that accepts user query parameters, validates them, and forwards a
Bearer-authenticated request to the catalog API. Return only the fields your UI needs (IDs, titles, tags,
dimensions, thumbnail_url / preview_url).
| Capability | API surface |
|---|---|
| Text search (title, description, tags) | q (max 200 characters) |
| Theme / filter chips | tags (up to 20 comma-separated) + tag_mode all|any |
| Result order | sort: relevance, newest, oldest |
| Page size | limit 1–100 (default 30) |
| Next page | cursor = previous next_cursor (opaque; never invent) |
| Auth | Authorization: Bearer … only (no query-string keys) |
Full parameter reference, response shape, and live gallery: Search & list images.
Keep secrets off the canvas. The design client talks to your API; your API talks to Epochal Stock.
Recommended integration path
GET /images · 30 req/min per account
q length and tag count before calling Epochalnext_cursor back to the client as an opaque token for “Load more”design-plugin-prod). Store it as EPOCHAL_API_KEY on the server only.
GET /api/stock/search that accepts
q, tags, tag_mode, sort, limit, and
cursor.
GET https://epochalstock.com/api/v1/images
with Authorization: Bearer … and Accept: application/json.
data.images; show data.total when useful;
if data.next_cursor is present, enable “Load more” with the same filters + cursor.
id. Use
GET /images/{id} or
POST /images/batch for richer panels,
and downloads for originals.
qUser free text. Default sort becomes relevance when q is set.
tags + tag_modeall (default) requires every tag; any matches at least one—ideal for multi-select chips.
sortrelevance for text search; newest / oldest for browse and “fresh stock” modes.
limit + cursorPrefer 24–48 for grids. Always reuse the server’s next_cursor unchanged.
Tip: Seed filter chips from
GET /tags
(tag + image_count), then search with
GET /images?tags=…. See
Tag-driven curation.
Search for “golden forest”, require both nature and trees, sort by newest,
return up to 30 results. Keys come from the environment—never from the design client.
curl -sS "https://epochalstock.com/api/v1/images?q=golden+forest&tags=nature,trees&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;
if (!key) throw new Error("Set EPOCHAL_API_KEY on the server");
const params = new URLSearchParams({
q: "golden forest",
tags: "nature,trees",
tag_mode: "all",
sort: "newest",
limit: "30",
});
const response = await fetch(`${base}/images?${params}`, {
headers: {
Authorization: `Bearer ${key}`,
Accept: "application/json",
},
});
if (!response.ok) {
// Surface 401 / 402 / 429 to your plugin via your own error shape
throw new Error(await response.text());
}
const { data, meta } = await response.json();
// data.images, data.total, data.next_cursor, data.filters
// meta.request_id — log for support
return {
images: data.images,
total: data.total,
nextCursor: data.next_cursor ?? null,
requestId: meta?.request_id,
};
$base = getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1';
$key = getenv('EPOCHAL_API_KEY');
if ($key === false || $key === '') {
throw new RuntimeException('Set EPOCHAL_API_KEY on the server');
}
$query = http_build_query([
'q' => 'golden forest',
'tags' => 'nature,trees',
'tag_mode' => 'all',
'sort' => 'newest',
'limit' => 30,
]);
$ch = curl_init(rtrim($base, '/') . '/images?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$response = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException((string) $response);
}
$payload = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
$data = $payload['data'];
// $data['images'], $data['total'], $data['next_cursor'], $data['filters']
import os
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1")
key = os.environ["EPOCHAL_API_KEY"]
r = requests.get(
f"{base}/images",
params={
"q": "golden forest",
"tags": "nature,trees",
"tag_mode": "all",
"sort": "newest",
"limit": 30,
},
headers={
"Authorization": f"Bearer {key}",
"Accept": "application/json",
},
timeout=20,
)
r.raise_for_status()
data = r.json()["data"]
print(data["total"], len(data["images"]), data.get("next_cursor"))
Design grids should request a fixed filter set and page with the opaque cursor. Replay the same
q, tags, tag_mode, sort, and limit on every page.
# First page
curl -sS "https://epochalstock.com/api/v1/images?q=forest&sort=newest&limit=24" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
# Next page — paste next_cursor exactly (URL-encode if needed)
curl -sS "https://epochalstock.com/api/v1/images?q=forest&sort=newest&limit=24&cursor=CURSOR_FROM_PREVIOUS" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
async function searchPage({ q, tags, tagMode = "all", sort = "newest", limit = 24, cursor }) {
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const params = new URLSearchParams({ sort, limit: String(limit) });
if (q) params.set("q", q);
if (tags) {
params.set("tags", Array.isArray(tags) ? tags.join(",") : tags);
params.set("tag_mode", tagMode);
}
if (cursor) params.set("cursor", cursor);
const res = await fetch(`${base}/images?${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();
return {
images: data.images,
total: data.total,
nextCursor: data.next_cursor ?? null,
};
}
function search_page(array $opts): array
{
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$params = [
'sort' => $opts['sort'] ?? 'newest',
'limit' => $opts['limit'] ?? 24,
];
if (!empty($opts['q'])) {
$params['q'] = $opts['q'];
}
if (!empty($opts['tags'])) {
$params['tags'] = is_array($opts['tags']) ? implode(',', $opts['tags']) : $opts['tags'];
$params['tag_mode'] = $opts['tag_mode'] ?? 'all';
}
if (!empty($opts['cursor'])) {
$params['cursor'] = $opts['cursor'];
}
$ch = curl_init($base . '/images?' . http_build_query($params));
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);
}
return json_decode($body, true, 512, JSON_THROW_ON_ERROR)['data'];
}
import os
import requests
def search_page(*, q=None, tags=None, tag_mode="all", sort="newest", limit=24, cursor=None):
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1")
params = {"sort": sort, "limit": limit}
if q:
params["q"] = q
if tags:
params["tags"] = ",".join(tags) if isinstance(tags, list) else tags
params["tag_mode"] = tag_mode
if cursor:
params["cursor"] = cursor
r = requests.get(
f"{base}/images",
params=params,
headers={
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
},
timeout=20,
)
r.raise_for_status()
return r.json()["data"]
invalid_cursor (HTTP 400)—restart from the first pageThe account budget is 30 authenticated requests per minute, shared across all named keys. Creating more keys does not multiply the limit. Debounce search-as-you-type on the client, and coalesce identical in-flight queries on the server.
| Header / signal | Meaning |
|---|---|
X-RateLimit-Limit |
Always 30 for catalog API traffic |
X-RateLimit-Remaining |
Requests left in the current window |
X-RateLimit-Reset |
Unix time when the window resets |
HTTP 429 · rate_limit_exceeded |
Honor Retry-After; back off with jitter |
Full error matrix and retry guidance: Rate limits & errors.
query_key_rejectedPOST /trial/signup)/collections, or /upload in documented v1 catalog APIFull GET /images parameter table, sample JSON, and live gallery.
Bearer header format, key lifecycle, and auth error codes.
Rehydrate large saved ID lists with POST /images/batch.
Discover themes via GET /tags and filter search by tags.