Product teams
Shipping “brand boards,” campaign walls, or creative brief spaces that need real stock metadata—not static image dumps.
App blueprint · Moodboard
Build a multi-user moodboard product where teams pin licensed stock into boards, sections, and
comments. Collections live in your application database as lists of Epochal image
IDs. You discover assets with GET /images and tags, then reopen boards efficiently with
POST /images/batch. There is no Epochal /collections endpoint to call—
and you should not invent one on their side.
Plan: $50/month, 30 req/min shared, max 20 keys, unlimited serial
originals, one concurrent transfer.
Shipping “brand boards,” campaign walls, or creative brief spaces that need real stock metadata—not static image dumps.
Internal tools where art directors pin references and designers open the same board later with fresh titles and previews.
Multi-tenant apps that already have projects, memberships, and permissions—and need a stock layer underneath.
Expecting Epochal to store board membership, comments, or folder trees. Those are your domain objects. Also not for browser-held API keys or free-tier trials (none in v1).
image_id (+ optional note, position, added_by).
GET /images with q,
tags, sort, cursor.
GET /tags, then filter search.
POST /images/batch (chunk >100).
GET /images/{id} for a large preview modal.
POST /downloads on the backend when
users need originals.
Critical distinction: “Collection” in your product UI is application data.
Epochal’s catalog may expose a collection field on an image record as metadata;
that is not a CRUD collections API for your boards. Do not call non-existent
/collections, GraphQL, or upload endpoints.
Multi-user clients never talk to Epochal with your secret. They talk to your API under your auth. Your API owns boards and calls the catalog with a Bearer key.
Browser / app UI → Your backend → Epochal API
Bearer key only on the backend. Front-end bundles, mobile apps, and shared “demo” keys in client env files will leak. Catalog access with secret keys from public browsers is not the supported integration path.
Keep Epochal IDs as opaque strings. Prefer not to mirror full catalog JSON long-term—rehydrate on open so titles, tags, and preview URLs stay current.
| Table | Key fields | Notes |
|---|---|---|
boards |
id, org_id, title, visibility |
Your tenancy & ACL |
board_members |
board_id, user_id, role |
viewer / editor |
board_pins |
board_id, image_id, note, sort_order, added_by |
image_id = Epochal id |
board_sections (optional) |
id, board_id, name, sort_order |
UI grouping only |
(board_id, image_id) unless you intentionally allow duplicates| Product action | Your system | Epochal (documented) |
|---|---|---|
| Create / rename / share board | Your DB + ACL | — |
| Search stock to pin | Proxy route | GET /images |
| Browse themes | Proxy route | GET /tags, GET /tags/{tag} |
| Add pin | Insert image_id in your DB |
Optional verify: GET /images/{id} |
| Open board (many pins) | Load IDs from DB | POST /images/batch (1–100 per call) |
| Pin detail modal | Proxy | GET /images/{id} |
| Export board originals | Job queue | POST /downloads + signed GET (serial) |
| Inventory badge | Ops/admin | GET /stats |
| “Epochal collections CRUD” | Does not exist | Do not invent /collections |
Discovery is the same catalog surface as the design plugin: free-text and tag filters on
GET https://epochalstock.com/api/v1/images. Seed tag chips from
GET /tags sorted by count so curators see large themes first.
Tags → filter search → pin ID to your board
See Asset search and Tag curation for parameter-level guidance.
POST /images/batch
Opening a board with 80 pins must not fire 80 detail requests. Batch accepts
1–100 unique IDs and counts as one authenticated request.
Missing IDs are omitted; compare found vs requested and show soft
placeholders for gaps.
One batch call. Merge returned images onto pin rows by id.
Chunk serially (or with low concurrency). Leave headroom for search traffic.
Flag unavailable assets; do not fail the whole board render.
80 detail calls burn most of a minute; one batch leaves budget for the team.
Full batch semantics: Image detail & batch · Bulk metadata use case.
Concurrency in a moodboard app is mostly your problem (who can edit, optimistic UI, presence). Epochal only constrains catalog rate and download leases at the account level.
409 download_already_active.
Two editors · one board · one catalog account
Pattern: load pin IDs from your database, rehydrate with batch, merge for the UI. Keys only on the server.
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";
const apiKey = process.env.EPOCHAL_API_KEY;
/** @param {string[]} pinIds from your DB */
async function hydratePins(pinIds) {
const unique = [...new Set(pinIds)];
const images = [];
let requested = 0;
let found = 0;
for (let i = 0; i < unique.length; i += 100) {
const ids = unique.slice(i, i + 100);
const res = await fetch(\`${base}/images/batch\`, {
method: "POST",
headers: {
Authorization: \`Bearer ${apiKey}\`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ ids }),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") || "2");
await new Promise((r) => setTimeout(r, wait * 1000));
i -= 100;
continue;
}
if (!res.ok) throw new Error(await res.text());
const { data } = await res.json();
images.push(...data.images);
requested += data.requested;
found += data.found;
}
const byId = new Map(images.map((img) => [img.id, img]));
return {
requested,
found,
cards: unique.map((id) => ({
imageId: id,
catalog: byId.get(id) ?? null, // null → show unavailable placeholder
})),
};
}
// Example route: GET /api/boards/:boardId
// 1) assert membership
// 2) const pinIds = await db.listPinIds(boardId)
// 3) return { pins: await hydratePins(pinIds) }
<?php
declare(strict_types=1);
function epochal_batch(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 epochal_batch($base, $apiKey, $ids);
}
if ($status !== 200) {
throw new RuntimeException((string) $raw);
}
return json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR)['data'];
}
/**
* @param list<string> $pinIds
* @return list<array{imageId: string, catalog: ?array}>
*/
function hydrate_pins(array $pinIds): array
{
$base = getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1';
$apiKey = getenv('EPOCHAL_API_KEY') ?: '';
$unique = array_values(array_unique($pinIds));
$images = [];
foreach (array_chunk($unique, 100) as $chunk) {
$data = epochal_batch($base, $apiKey, $chunk);
foreach ($data['images'] as $img) {
$images[$img['id']] = $img;
}
}
$cards = [];
foreach ($unique as $id) {
$cards[] = [
'imageId' => $id,
'catalog' => $images[$id] ?? null,
];
}
return $cards;
}
import os
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"]
def hydrate_pins(pin_ids: list[str]) -> list[dict[str, Any]]:
unique = list(dict.fromkeys(pin_ids)) # stable dedupe
by_id: dict[str, dict] = {}
for i in range(0, len(unique), 100):
chunk = unique[i : i + 100]
r = requests.post(
f"{BASE}/images/batch",
json={"ids": chunk},
headers={
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json",
},
timeout=20,
)
if r.status_code == 429:
import time
time.sleep(float(r.headers.get("Retry-After", "2")))
# simple retry of same chunk
r = requests.post(
f"{BASE}/images/batch",
json={"ids": chunk},
headers={
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json",
},
timeout=20,
)
r.raise_for_status()
for img in r.json()["data"]["images"]:
by_id[img["id"]] = img
return [
{"imageId": i, "catalog": by_id.get(i)} # None → unavailable
for i in unique
]
# Discover
curl -sS "https://epochalstock.com/api/v1/images?q=editorial+portrait&tags=people&tag_mode=all&limit=24" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
# Your app then INSERT board_pins(board_id, image_id, ...) — not an Epochal write
// POST /api/boards/:boardId/pins { "imageId": "es_001_example", "note": "hero option" }
app.post("/api/boards/:boardId/pins", async (req, res) => {
await assertCanEdit(req.user, req.params.boardId);
const imageId = String(req.body.imageId || "");
if (!imageId) return res.status(400).json({ error: "imageId_required" });
// Optional: verify still exists (1 rate unit)
const check = await fetch(
\`${base}/images/${encodeURIComponent(imageId)}\`,
{ headers: { Authorization: \`Bearer ${apiKey}\`, Accept: "application/json" } }
);
if (check.status === 404) return res.status(404).json({ error: "image_not_found" });
if (!check.ok) return res.status(502).json({ error: "catalog_error" });
await db.insertPin({
boardId: req.params.boardId,
imageId,
note: String(req.body.note || "").slice(0, 500),
addedBy: req.user.id,
});
res.status(201).json({ ok: true });
});
// Pseudocode: pin after optional detail check
$imageId = (string) ($body['imageId'] ?? '');
// ... ACL on $boardId ...
$ch = curl_init(rtrim($base, '/') . '/images/' . rawurlencode($imageId));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 404) {
http_response_code(404);
exit;
}
// INSERT INTO board_pins (board_id, image_id, note, added_by) VALUES (...)
# FastAPI: pin image_id into YOUR database only
@app.post("/api/boards/{board_id}/pins")
async def add_pin(board_id: str, image_id: str, note: str = ""):
await assert_can_edit(board_id)
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(
f"{BASE}/images/{image_id}",
headers={"Authorization": f"Bearer {API_KEY}", "Accept": "application/json"},
)
if r.status_code == 404:
raise HTTPException(404, "image_not_found")
if r.status_code != 200:
raise HTTPException(502, "catalog_error")
await db.insert_pin(board_id, image_id, note[:500])
return {"ok": True}
Never share one Epochal key with end-user browsers. Multi-tenant moodboard
frontends must use your session; your servers hold EPOCHAL_API_KEY.
Board privacy is your job. Epochal image metadata is catalog data. If a board is private, only your ACL may return hydrated cards for those pin IDs—even though the same IDs are discoverable via public search on an authorized account.
download_url values to untrusted clients if you can stream instead/upload, or /collections CRUD on Epochal v1 (free 1-month trial: 2,000 requests at 2/min via POST /trial/signup)Account budget: 30 authenticated req/min, shared by all keys (max 20). Batch is the efficiency tool for reopen; search still costs one unit per page. Original downloads are unlimited in count but one concurrent transfer per account—coordinate export jobs across the team.
| Scenario | Approx. catalog cost |
|---|---|
| Open board with 40 pins (one batch) | 1 request |
| Open board with 250 pins (3 batches) | 3 requests |
| Search 3 pages while pinning | 3 requests |
| 40 × GET detail instead of batch | 40 requests (avoid) |
| Export 10 originals | 10 × POST /downloads (+ serial transfers) |
image_id valuesPOST /images/batch; handle missing IDs softlyChunking, partial matches, and batch efficiency.
Drive discovery with GET /tags and tag filters.
In-editor search, place preview, export originals.
Named keys per client project under one shared rate limit.