Who builds it
WordPress / Drupal / Ghost plugin authors, headless CMS teams (Contentful-style apps), SaaS blog platforms, and in-house newsroom tooling.
App blueprint · CMS illustrator
Editorial teams need hero images, inline figures, and Open Graph art without leaving the CMS.
This blueprint shows how to build an auto-illustrator that derives search hints from
the post title and body on your server, calls
GET https://epochalstock.com/api/v1/images, lets the editor pick a licensed asset,
and stores only the stable image_id on the content record—with optional full-resolution
export for social cards via the documented two-step download flow.
The product is a CMS plugin or headless editorial service—not a public browser client of the Image API.
Authors write a draft; the illustrator panel proposes stock images that match the story; the author
confirms one (or several); your database records image_id values and preview metadata
for the editor UI. When you need a full-resolution file (for example an OG image pipeline or print
export), a backend worker mints a download token and fetches the original serially.
WordPress / Drupal / Ghost plugin authors, headless CMS teams (Contentful-style apps), SaaS blog platforms, and in-house newsroom tooling.
A “Suggest images” panel next to title/body, a grid of thumbnails and titles, pick/replace controls, and optional “export original for social.”
Prefer image_id (and maybe title, dimensions, thumbnail URL snapshot) on the post.
Rehydrate live metadata with detail or batch when the editor reopens.
One $50/month subscription, full catalog, shared 30 req/min account budget, unlimited serial originals (concurrency 1).
Non-negotiable: the browser (or CMS admin SPA) talks only to your CMS/backend.
Your backend holds EPOCHAL_API_KEY and calls Epochal. Never ship a secret key in a
plugin zip, theme, or admin JavaScript bundle. Authenticated catalog CORS is not a supported
public-browser pattern.
Keep secrets on the application server that already authenticates editors. Map UI actions to thin internal routes that validate session + capability, then forward documented Epochal calls.
Keys stay on the CMS server
GET /images · batch · downloads
image_id · serve previews
| Editor action | Your route (example) | Epochal surface |
|---|---|---|
| Suggest from draft | POST /cms/illustrator/suggest |
GET /images with server-built q / tags |
| Refine search / load more | GET /cms/illustrator/search |
GET /images + opaque cursor |
| Inspect one asset | GET /cms/illustrator/images/{id} |
GET /images/{id} |
| Attach to post | PUT /cms/posts/{id}/hero |
None (write your DB) |
| Reopen post with many IDs | GET /cms/posts/{id}/media |
POST /images/batch |
| Export original for OG | POST /cms/illustrator/export (worker) |
POST /downloads then token GET |
| Seed tag chips | GET /cms/illustrator/tags (cached) |
GET /tags |
| Ops / health widget | Internal admin only | GET /stats (and public health if you already use it) |
EPOCHAL_API_KEY in CMS server env / secrets manager—never in the database as a public option field visible to all admins without ACL.q at 200 characters and tags at 20.X-Request-ID / meta.request_id on failures for support—never log the API key or full signed download URLs long-term.GET /images.
GET /tags (cache aggressively). Support “load more” with
next_cursor.
GET /images/{id} for a richer inspector (description, exact dimensions).
image_id (and role: hero, inline, OG) on the post.
Do not re-download originals on every page view.
image_id,
rehydrate with POST /images/batch (1–100 IDs per request).
q and tags on the server
Free-text search (q) covers title, description, and tags in the catalog. Your CMS should
build the query string server-side from trusted post fields—not trust a raw
client-supplied string without length checks. Tag filters are optional but powerful when editors
already use a controlled vocabulary that maps to Epochal tags.
qStart with the post title (trimmed, max 200 chars). Often enough for a first suggestion page.
Strip HTML, drop stop-words, keep the top few distinctive tokens, and append them to
q—still under 200 characters total.
If the post has taxonomy terms that match normalized Epochal tags, pass
tags=… with tag_mode=any (broad) or all (strict).
Always allow manual refine: the same search route accepts an explicit q and
tag chips after the first automatic suggestion.
// Conceptual server helper — not an Epochal endpoint
function buildSearchHints(post):
q = truncate(clean(post.title), 200)
if length(q) < 12:
q = truncate(join(topKeywords(stripHtml(post.body)), " "), 200)
tags = mapCmsTermsToEpochalTags(post.terms) // max 20
tag_mode = "any" if tags.length > 1 else "all"
return { q, tags, tag_mode, sort: "relevance", limit: 24 }
Seed chip UIs from
GET /tags
(each tag includes image_count). Cache tag lists; they change slowly compared with
search traffic. See also
Tag-driven curation.
GET /imagesPrimary discovery surface. Parameters you will actually use in an illustrator panel:
| Parameter | Illustrator usage |
|---|---|
q |
Derived or editor-edited free text (max 200 characters). |
tags + tag_mode |
Up to 20 tags; all or any. |
sort |
relevance when q is set; newest for “fresh stock” browse. |
limit |
24–30 for editor grids (max 100). |
cursor |
Opaque next_cursor from the previous page—never invent. |
Draft → hints → GET /images → editor grid
Full parameter reference: Search & list images · product-oriented search guide: Design asset search.
image_idOnce the editor confirms a selection, write a small media attachment record owned by your CMS. That record is the source of truth for templates—not a live re-search on every public page view.
post_id, image_id, role (hero | inline | og),
optional title / dimensions snapshot for offline admin views.
Replacing a hero is a CMS write: new image_id, optional purge of a previously
exported OG file in your storage.
Prefer serving your own CDN assets or catalog preview URLs already returned by the API— do not mint download tokens on anonymous traffic.
Follow the Epochal Stock commercial license. Do not re-expose the catalog as a competing public stock browser with your key.
Catalog responses include thumbnails and watermarked previews only. When your social pipeline needs a full-resolution file, use the documented download pair—from a worker, not from the editor browser:
POST https://epochalstock.com/api/v1/downloads with
Authorization: Bearer … and {"image_id":"…"} → HTTP 201
and a five-minute, single-use download_url.
GET on that URL without the API key. Store the bytes in private
object storage; run your OG crop/resize with your own image library.
Accounts allow one concurrent original transfer. If multiple editors export at
once, serialize jobs or you will see 409 download_already_active. Serial downloads
are otherwise unlimited—there is no per-download credit meter in v1.
Deep dive:
Safe original export pipeline
·
Downloads reference.
download_url as sensitive until used or expired; do not put it in the browser.409 with backoff; handle 429 with Retry-After.image_not_found, original_unavailable) instead of tight-looping.
List views (“latest posts,” “related stories,” editorial dashboards) often hold dozens of stored
image_id values. Calling GET /images/{id} once per card wastes
the shared 30 req/min budget. Instead:
POST https://epochalstock.com/api/v1/images/batch with
{"ids":[…]} — 1–100 unique IDs per request, one
rate unit per call.
found vs requested; omit missing IDs gracefully in the UI.
Guide: Bulk metadata · reference: Detail & batch.
Server-side only. Base URL defaults to https://epochalstock.com/api/v1.
Load EPOCHAL_API_KEY from the environment.
Build hints, then call GET /images. Map 401/402/429 to CMS-friendly errors.
# After your CMS derives q (and optional tags) server-side:
curl -sS "https://epochalstock.com/api/v1/images?q=remote+work+desk&tags=office,technology&tag_mode=any&sort=relevance&limit=24" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json"
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const apiKey = process.env.EPOCHAL_API_KEY;
if (!apiKey) throw new Error("Set EPOCHAL_API_KEY on the CMS server");
const STOP = new Set(["the", "a", "an", "and", "or", "to", "of", "in", "on", "for", "with"]);
/** Derive q/tags from CMS post fields — runs only on your backend */
function buildHints({ title = "", bodyHtml = "", cmsTags = [] }) {
let q = String(title).replace(/\s+/g, " ").trim();
if (q.length < 12) {
const text = String(bodyHtml).replace(/<[^>]+>/g, " ");
const tokens = text
.toLowerCase()
.split(/[^a-z0-9]+/i)
.filter((t) => t.length > 3 && !STOP.has(t));
q = [...new Set(tokens)].slice(0, 8).join(" ");
}
q = q.slice(0, 200);
const tags = [...new Set(cmsTags.map((t) => String(t).toLowerCase().trim()).filter(Boolean))].slice(0, 20);
return {
q,
tags: tags.join(","),
tag_mode: tags.length > 1 ? "any" : "all",
sort: q ? "relevance" : "newest",
limit: "24",
};
}
async function suggestForPost(post, { cursor } = {}) {
const hints = buildHints(post);
const params = new URLSearchParams({
sort: hints.sort,
limit: hints.limit,
});
if (hints.q) params.set("q", hints.q);
if (hints.tags) {
params.set("tags", hints.tags);
params.set("tag_mode", hints.tag_mode);
}
if (cursor) params.set("cursor", cursor);
const res = await fetch(`${base}/images?${params}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") || "2");
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return suggestForPost(post, { cursor });
}
if (!res.ok) throw new Error(await res.text());
const { data, meta } = await res.json();
return {
images: data.images,
total: data.total,
nextCursor: data.next_cursor ?? null,
filters: data.filters,
requestId: meta?.request_id,
hints,
};
}
// Example: CMS route handler receives session-authenticated editor + post id
// const result = await suggestForPost({ title, bodyHtml, cmsTags });
<?php
declare(strict_types=1);
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$apiKey = getenv('EPOCHAL_API_KEY');
if (!$apiKey) {
throw new RuntimeException('Set EPOCHAL_API_KEY on the CMS server');
}
/**
* @param array{title?:string,body_html?:string,cms_tags?:list<string>} $post
* @return array{q:string,tags:string,tag_mode:string,sort:string,limit:int}
*/
function build_hints(array $post): array
{
$stop = ['the','a','an','and','or','to','of','in','on','for','with'];
$q = trim(preg_replace('/\s+/', ' ', (string) ($post['title'] ?? '')) ?? '');
if (strlen($q) < 12) {
$text = strip_tags((string) ($post['body_html'] ?? ''));
$parts = preg_split('/[^a-z0-9]+/i', strtolower($text)) ?: [];
$tokens = [];
foreach ($parts as $t) {
if (strlen($t) > 3 && !in_array($t, $stop, true)) {
$tokens[$t] = true;
}
}
$q = implode(' ', array_slice(array_keys($tokens), 0, 8));
}
$q = substr($q, 0, 200);
$tags = [];
foreach ($post['cms_tags'] ?? [] as $t) {
$n = strtolower(trim((string) $t));
if ($n !== '') {
$tags[$n] = true;
}
}
$tagList = array_slice(array_keys($tags), 0, 20);
return [
'q' => $q,
'tags' => implode(',', $tagList),
'tag_mode' => count($tagList) > 1 ? 'any' : 'all',
'sort' => $q !== '' ? 'relevance' : 'newest',
'limit' => 24,
];
}
/**
* @return array<string,mixed>
*/
function suggest_for_post(string $base, string $apiKey, array $post, ?string $cursor = null): array
{
$hints = build_hints($post);
$params = [
'sort' => $hints['sort'],
'limit' => $hints['limit'],
];
if ($hints['q'] !== '') {
$params['q'] = $hints['q'];
}
if ($hints['tags'] !== '') {
$params['tags'] = $hints['tags'];
$params['tag_mode'] = $hints['tag_mode'];
}
if ($cursor) {
$params['cursor'] = $cursor;
}
$ch = curl_init($base . '/images?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 429) {
sleep(2);
return suggest_for_post($base, $apiKey, $post, $cursor);
}
if ($status !== 200) {
throw new RuntimeException((string) $raw);
}
$payload = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR);
return [
'hints' => $hints,
'data' => $payload['data'],
'request_id' => $payload['meta']['request_id'] ?? null,
];
}
// $result = suggest_for_post($base, $apiKey, [
// 'title' => 'Remote work desk setups that actually focus',
// 'body_html' => '<p>Monitors, lighting, ergonomic chairs…</p>',
// 'cms_tags' => ['office', 'technology'],
// ]);
// Persist nothing yet — return $result['data']['images'] to the authenticated editor UI.
import os
import re
import time
from typing import Any
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
api_key = os.environ["EPOCHAL_API_KEY"]
STOP = {"the", "a", "an", "and", "or", "to", "of", "in", "on", "for", "with"}
def build_hints(title: str = "", body_html: str = "", cms_tags: list[str] | None = None) -> dict[str, Any]:
q = re.sub(r"\s+", " ", title or "").strip()
if len(q) < 12:
text = re.sub(r"<[^>]+>", " ", body_html or "")
tokens = [
t for t in re.split(r"[^a-z0-9]+", text.lower())
if len(t) > 3 and t not in STOP
]
# preserve order, unique
seen: dict[str, None] = {}
for t in tokens:
seen.setdefault(t, None)
q = " ".join(list(seen.keys())[:8])
q = q[:200]
tags = []
for t in cms_tags or []:
n = t.strip().lower()
if n and n not in tags:
tags.append(n)
tags = tags[:20]
return {
"q": q,
"tags": ",".join(tags),
"tag_mode": "any" if len(tags) > 1 else "all",
"sort": "relevance" if q else "newest",
"limit": 24,
}
def suggest_for_post(post: dict, cursor: str | None = None) -> dict:
hints = build_hints(
title=post.get("title", ""),
body_html=post.get("body_html", ""),
cms_tags=post.get("cms_tags"),
)
params: dict[str, Any] = {"sort": hints["sort"], "limit": hints["limit"]}
if hints["q"]:
params["q"] = hints["q"]
if hints["tags"]:
params["tags"] = hints["tags"]
params["tag_mode"] = hints["tag_mode"]
if cursor:
params["cursor"] = cursor
r = requests.get(
f"{base}/images",
params=params,
headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
timeout=20,
)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "2")))
return suggest_for_post(post, cursor)
r.raise_for_status()
body = r.json()
return {"hints": hints, "data": body["data"], "request_id": body.get("meta", {}).get("request_id")}
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
var nonWord = regexp.MustCompile(`[^a-z0-9]+`)
func buildHints(title, bodyHTML string, cmsTags []string) url.Values {
q := strings.Join(strings.Fields(title), " ")
if len(q) < 12 {
text := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(bodyHTML, " ")
parts := nonWord.Split(strings.ToLower(text), -1)
stop := map[string]bool{"the": true, "a": true, "an": true, "and": true, "or": true, "to": true, "of": true, "in": true, "on": true, "for": true, "with": true}
var tokens []string
seen := map[string]bool{}
for _, t := range parts {
if len(t) > 3 && !stop[t] && !seen[t] {
seen[t] = true
tokens = append(tokens, t)
if len(tokens) == 8 {
break
}
}
}
q = strings.Join(tokens, " ")
}
if len(q) > 200 {
q = q[:200]
}
tags := []string{}
tseen := map[string]bool{}
for _, t := range cmsTags {
n := strings.ToLower(strings.TrimSpace(t))
if n != "" && !tseen[n] {
tseen[n] = true
tags = append(tags, n)
if len(tags) == 20 {
break
}
}
}
v := url.Values{}
if q != "" {
v.Set("q", q)
v.Set("sort", "relevance")
} else {
v.Set("sort", "newest")
}
if len(tags) > 0 {
v.Set("tags", strings.Join(tags, ","))
if len(tags) > 1 {
v.Set("tag_mode", "any")
} else {
v.Set("tag_mode", "all")
}
}
v.Set("limit", "24")
return v
}
func suggest(base, apiKey string, q url.Values) (map[string]any, error) {
req, _ := http.NewRequest(http.MethodGet, strings.TrimRight(base, "/")+"/images?"+q.Encode(), 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 nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode == 429 {
time.Sleep(2 * time.Second)
return suggest(base, apiKey, q)
}
if res.StatusCode != 200 {
return nil, fmt.Errorf("search %d: %s", res.StatusCode, b)
}
var payload map[string]any
if err := json.Unmarshal(b, &payload); err != nil {
return nil, err
}
return payload, nil
}
func main() {
base := os.Getenv("EPOCHAL_API_BASE")
if base == "" {
base = "https://epochalstock.com/api/v1"
}
apiKey := os.Getenv("EPOCHAL_API_KEY")
q := buildHints("Remote work desk setups", "<p>Monitors and lighting</p>", []string{"office", "technology"})
// payload, err := suggest(base, apiKey, q)
_, _ = base, apiKey
_ = q
}
After pick: optional GET /images/{id}, then store image_id in your DB.
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
async function fetchImage(imageId) {
const res = await fetch(`${base}/images/${encodeURIComponent(imageId)}`, {
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;
}
/** Editor confirmed pick — no Epochal write API; this is your CMS */
async function attachHero(postId, imageId, db) {
const meta = await fetchImage(imageId);
await db.posts.update(postId, {
hero_image_id: meta.id,
hero_title_snapshot: meta.title,
hero_width: meta.width,
hero_height: meta.height,
hero_thumb_snapshot: meta.thumbnail_url,
});
return meta;
}
<?php
function fetch_image(string $base, string $apiKey, string $imageId): array
{
$ch = curl_init(rtrim($base, '/') . '/images/' . rawurlencode($imageId));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException((string) $raw);
}
return json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR)['data'];
}
// attach_hero($pdo, $postId, $imageId): UPDATE posts SET hero_image_id = ? …
import os
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
def fetch_image(image_id: str) -> dict:
r = requests.get(
f"{base}/images/{image_id}",
headers={
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
},
timeout=20,
)
r.raise_for_status()
return r.json()["data"]
def attach_hero(db, post_id: int, image_id: str) -> dict:
meta = fetch_image(image_id)
db.execute(
"UPDATE posts SET hero_image_id=%s, hero_title=%s WHERE id=%s",
(meta["id"], meta["title"], post_id),
)
return meta
curl -sS "https://epochalstock.com/api/v1/images/es_001_example" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json"
# Then UPDATE your posts table with data.id — there is no Epochal "attach" endpoint.
curl -sS -X POST "https://epochalstock.com/api/v1/images/batch" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"ids":["es_001_example","es_002_example","es_003_example"]}'
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
async function batchOnce(ids) {
const res = await fetch(`${base}/images/batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EPOCHAL_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ ids }),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") || "2");
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return batchOnce(ids);
}
if (!res.ok) throw new Error(await res.text());
return (await res.json()).data;
}
/** Rehydrate hero cards for a page of posts (chunk at 100) */
async function hydrateHeroes(imageIds) {
const unique = [...new Set(imageIds.filter(Boolean))];
const images = [];
for (let i = 0; i < unique.length; i += 100) {
const data = await batchOnce(unique.slice(i, i + 100));
images.push(...data.images);
}
return new Map(images.map((img) => [img.id, img]));
}
<?php
function batch_once(string $base, string $apiKey, array $ids): array
{
$ch = curl_init(rtrim($base, '/') . '/images/batch');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['ids' => array_values($ids)], JSON_THROW_ON_ERROR),
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 429) {
sleep(2);
return batch_once($base, $apiKey, $ids);
}
if ($status !== 200) {
throw new RuntimeException((string) $raw);
}
return json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR)['data'];
}
function hydrate_heroes(string $base, string $apiKey, array $imageIds): array
{
$unique = array_values(array_unique(array_filter($imageIds)));
$byId = [];
foreach (array_chunk($unique, 100) as $chunk) {
$data = batch_once($base, $apiKey, $chunk);
foreach ($data['images'] as $img) {
$byId[$img['id']] = $img;
}
}
return $byId;
}
import os
import time
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
def batch_once(ids: list[str]) -> dict:
r = requests.post(
f"{base}/images/batch",
json={"ids": ids},
headers={
"Authorization": f"Bearer {os.environ['EPOCHAL_API_KEY']}",
"Accept": "application/json",
},
timeout=20,
)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "2")))
return batch_once(ids)
r.raise_for_status()
return r.json()["data"]
def hydrate_heroes(image_ids: list[str]) -> dict[str, dict]:
unique = list(dict.fromkeys([i for i in image_ids if i]))
out: dict[str, dict] = {}
for i in range(0, len(unique), 100):
data = batch_once(unique[i : i + 100])
for img in data["images"]:
out[img["id"]] = img
return out
Mint token → GET binary without key → store privately. One concurrent transfer per account.
import { writeFile } from "node:fs/promises";
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const apiKey = process.env.EPOCHAL_API_KEY;
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function exportOriginal(imageId, destPath, attempt = 1) {
const mint = await fetch(`${base}/downloads`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ image_id: imageId }),
});
if (mint.status === 409) {
// download_already_active — another original is in flight on this account
if (attempt >= 8) throw new Error("lease busy too long");
await sleep(Math.min(30_000, 500 * 2 ** (attempt - 1)) + Math.random() * 250);
return exportOriginal(imageId, destPath, attempt + 1);
}
if (mint.status === 429) {
await sleep(Number(mint.headers.get("Retry-After") || "5") * 1000);
return exportOriginal(imageId, destPath, attempt + 1);
}
if (!mint.ok) throw new Error(`mint ${mint.status}: ${await mint.text()}`);
const { data } = await mint.json();
const fileRes = await fetch(data.download_url); // no Authorization
if (!fileRes.ok) throw new Error(`download ${fileRes.status}`);
await writeFile(destPath, Buffer.from(await fileRes.arrayBuffer()));
// Next: your OG crop/resize pipeline reads destPath
}
// Worker processes one job at a time for the account:
// await exportOriginal(job.imageId, job.destPath);
<?php
function export_original(string $base, string $apiKey, string $imageId, string $destPath, int $attempt = 1): void
{
$ch = curl_init(rtrim($base, '/') . '/downloads');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['image_id' => $imageId], JSON_THROW_ON_ERROR),
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 409) {
if ($attempt >= 8) {
throw new RuntimeException('lease busy too long');
}
usleep((int) ((500 * (2 ** ($attempt - 1))) + random_int(0, 250)) * 1000);
export_original($base, $apiKey, $imageId, $destPath, $attempt + 1);
return;
}
if ($status === 429) {
sleep(5);
export_original($base, $apiKey, $imageId, $destPath, $attempt + 1);
return;
}
if ($status !== 201) {
throw new RuntimeException('mint failed: ' . (string) $raw);
}
$url = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR)['data']['download_url'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
$bytes = curl_exec($ch);
$dl = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($dl !== 200) {
throw new RuntimeException('download failed: ' . $dl);
}
file_put_contents($destPath, $bytes);
}
import os
import random
import time
import requests
base = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
api_key = os.environ["EPOCHAL_API_KEY"]
def export_original(image_id: str, dest_path: str, attempt: int = 1) -> None:
mint = requests.post(
f"{base}/downloads",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={"image_id": image_id},
timeout=20,
)
if mint.status_code == 409:
if attempt >= 8:
raise RuntimeError("lease busy too long")
time.sleep(min(30, 0.5 * (2 ** (attempt - 1))) + random.random() * 0.25)
return export_original(image_id, dest_path, attempt + 1)
if mint.status_code == 429:
time.sleep(int(mint.headers.get("Retry-After", "5")))
return export_original(image_id, dest_path, attempt + 1)
mint.raise_for_status()
url = mint.json()["data"]["download_url"]
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(dest_path, "wb") as f:
for chunk in r.iter_content(256 * 1024):
if chunk:
f.write(chunk)
# Mint (Bearer required)
curl -sS -X POST "https://epochalstock.com/api/v1/downloads" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"image_id":"es_001_example"}'
# Then GET data.download_url with NO API key; save to private storage for OG processing.
| Item | v1 value | CMS implication |
|---|---|---|
| Price | $50 USD/month | One subscription for the whole product account. |
| Catalog API | 30 req/min per account | Search, detail, batch, tags, stats, and download mints share the budget. |
| Original downloads | Unlimited serial · concurrency 1 | Queue OG exports; do not fan out parallel mints. |
| Named keys | Up to 20 | e.g. cms-prod, cms-staging—still one rate limit and one download lease. |
GET /tags and GET /stats; do not poll them on every keystroke.
No AI image generation endpoints. Epochal Stock Developer Image API v1 is a
licensed catalog API: search, metadata, tags, stats, and original downloads.
There is no documented text-to-image, inpainting, background-removal, or “auto-generate hero”
model endpoint. If your product needs generative fills, that is a separate vendor or your own
model stack—do not invent Epochal paths such as /generate or /ai/*.
query_key_rejected).POST /trial/signup).Before you ship the illustrator panel
q/tags server-side; enforce 200-char and 20-tag caps.cursor.image_id (+ role) on the post; rehydrate with detail or batch.POST /downloads + token GET on a serial worker.Deep dive on GET /images parameters, pagination, and plugin-safe proxying.
Chunked POST /images/batch for dashboards and related-post grids.
Serial queue, 409 handling, and key-safe two-step downloads.
When you need print-ready originals and mockups (composite stays in your app).