Digital agencies
Multiple concurrent brands, each needing search, boards, and export without buying a separate stock plan per client.
App blueprint · Agency library
Run one licensed stock layer for many client projects: a shared library UI, per-project credentials, and careful rate budgeting. On the Developer Image API you operate one Epochal account on the single $50/month plan, create up to 20 named API keys, and accept that all keys share 30 requests per minute and one concurrent original transfer. This blueprint shows how to structure products, keys, inventory views, and ops without inventing endpoints.
Multiple concurrent brands, each needing search, boards, and export without buying a separate stock plan per client.
Central design systems teams that expose an internal “stock library” portal to brand squads.
Building multi-tenant backends that map projects to named keys, secrets managers, and shared download workers.
Reselling unlimited public API access under one key, free-tier demos, video stock, or assuming keys multiply the 30/min limit.
GET /images), tags, detail drawers for staff.
client-acme-prod, client-globex-staging
(max 20 total on the account).
GET /stats plus popular tags via GET /tags.
POST /downloads for the whole account.
X-RateLimit-* headers in ops dashboards.
Plan facts (v1): $50 USD/month (first month $40 with the 20% upgrade discount) ·
full image catalog · 30 authenticated req/min per account · up to 20 named keys · unlimited serial
original downloads · one concurrent original transfer · free 1-month trial (2,000 requests at 2/min
via POST /trial/signup) · no per-image credits or video.
Staff browsers and client-facing tools always stop at your backend. Which Epochal key a worker uses may depend on the project, but the key never leaves your infrastructure.
Browser / plugin UI → Your backend → Epochal API
One account, shared ceilings. Twenty keys do not mean 20 × 30 req/min or 20 parallel originals. Budget search, batch, and mint traffic as a single pool. See Rate limits & errors.
Keys are for isolation, rotation, and attribution—not for multiplying throughput. Give each long-lived environment a clear name when creating keys through the management/portal flow (secret shown once).
| Key name (example) | Purpose | Guidance |
|---|---|---|
agency-library-prod |
Primary portal + search proxy | Default for interactive staff traffic |
agency-export-worker |
Serial download minting | Only workers; easier to rotate if a job host leaks |
client-acme-prod |
Dedicated integration for one client system | Revoke without touching other clients |
client-globex-staging |
Non-prod experiments | Still shares 30/min—cap automation |
project_id → key_id / secret ref in your secrets manager—not in gitX-Request-IDLifecycle, rotation, and billing context: Authentication · Security & billing.
Interactive designers, nightly rehydrate jobs, and export mints compete for the same 30 authenticated requests per minute. Design the agency library so background work is polite.
Prioritize human search latency. Pause or slow batch rehydrate jobs during business hours if headers show low remaining.
Project manifests and shared boards should use POST /images/batch (≤100 IDs)
instead of per-id detail storms.
Short-TTL caches for identical GET /images queries across the team save the pool.
One global download mutex for the account. Queue client export jobs; handle
409 download_already_active.
| Workload | Suggested cap |
|---|---|
| Staff search (peak) | Leave ~10–15 req/min headroom for everyone else |
| Background batch rehydrate | 1–2 req/s max bursts; back off on 429 / low remaining |
| Export minting | Serialize; each mint is 1 catalog unit |
| Staging / CI | Hard client-side mock when possible; never load-test against prod key |
On HTTP 429 / rate_limit_exceeded, honor Retry-After and
X-RateLimit-Reset. Do not spin additional keys to “get around” the limit—it will not work.
Agency ops and producers often ask “how big is the catalog?” and “what themes are rich enough for this pitch?” Use documented read APIs—not scraped UIs.
Inventory signals from documented endpoints
GET /stats for several minutes—it rarely needs per-click freshness/collections| Agency capability | Your system | Epochal endpoint |
|---|---|---|
| Global search in library | Authenticated proxy | GET /images |
| Asset inspector | Proxy | GET /images/{id} |
| Reopen client board / brief | Load IDs from your DB | POST /images/batch |
| Theme browser | Proxy + cache | GET /tags, GET /tags/{tag} |
| Catalog size widget | Ops job | GET /stats |
| Client package export | Global serial worker | POST /downloads + signed GET |
| Project / client folders | Your database | — |
| Key create / revoke | Portal / management API (account session) | See security-billing docs—not browser catalog keys |
Assign a human owner for the Epochal subscription, PayPal state, and key inventory. Expired or inactive subscriptions block catalog access for every project at once.
Track 401/402/429 rates, X-RateLimit-Remaining percentiles, and export queue
depth. Store X-Request-ID on failures.
Revoke the named key immediately, deploy a replacement secret, flush any malicious traffic patterns. Do not rotate by committing a new key to the repo.
Revoke client-specific keys, archive their pin lists in your DB, and confirm the export worker no longer schedules their jobs.
Billing states, management endpoints, and the security checklist live on Security, account & billing. Rate headers and error codes: Rate limits & errors.
Patterns below assume secrets are resolved per project from your vault, then used only on the server
against https://epochalstock.com/api/v1.
# Ops: stats snapshot (cache this)
curl -sS "https://epochalstock.com/api/v1/stats" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json"
# Search under the same account budget
curl -sS "https://epochalstock.com/api/v1/images?q=luxury+packaging&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";
/** @type {Record<string, string>} projectId -> secret from vault inject */
const projectKeys = {
// populated at boot from secrets manager — never hardcode real secrets
};
function keyForProject(projectId) {
const key = projectKeys[projectId] || process.env.EPOCHAL_API_KEY;
if (!key) throw new Error("no Epochal key configured");
return key;
}
async function librarySearch(projectId, { q, tags, limit = 24, cursor }) {
const params = new URLSearchParams({
sort: q ? "relevance" : "newest",
limit: String(Math.min(100, limit)),
});
if (q) params.set("q", String(q).slice(0, 200));
if (tags) params.set("tags", tags);
if (cursor) params.set("cursor", cursor);
const res = await fetch(\`${base}/images?${params}\`, {
headers: {
Authorization: \`Bearer ${keyForProject(projectId)}\`,
Accept: "application/json",
},
});
const remaining = res.headers.get("x-ratelimit-remaining");
const reset = res.headers.get("x-ratelimit-reset");
// metrics.gauge("epochal.remaining", remaining)
if (res.status === 429) {
const err = new Error("rate_limit_exceeded");
err.retryAfter = res.headers.get("retry-after");
throw err;
}
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
return {
images: body.data.images,
total: body.data.total,
nextCursor: body.data.next_cursor ?? null,
rate: { remaining, reset },
requestId: body.meta?.request_id,
};
}
<?php
declare(strict_types=1);
function project_api_key(string $projectId): string
{
// Resolve from secrets manager / encrypted config — example uses env map
$mapJson = getenv('EPOCHAL_PROJECT_KEYS_JSON') ?: '{}';
$map = json_decode($mapJson, true, 512, JSON_THROW_ON_ERROR);
$key = $map[$projectId] ?? getenv('EPOCHAL_API_KEY');
if (!$key) {
throw new RuntimeException('no Epochal key for project');
}
return $key;
}
function library_search(string $projectId, array $opts): array
{
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$q = substr((string) ($opts['q'] ?? ''), 0, 200);
$params = [
'sort' => $q !== '' ? 'relevance' : 'newest',
'limit' => max(1, min(100, (int) ($opts['limit'] ?? 24))),
];
if ($q !== '') {
$params['q'] = $q;
}
if (!empty($opts['tags'])) {
$params['tags'] = $opts['tags'];
}
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_HEADER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . project_api_key($projectId),
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$headers = substr((string) $raw, 0, $headerSize);
$body = substr((string) $raw, $headerSize);
if ($status === 429) {
throw new RuntimeException('rate_limit_exceeded');
}
if ($status !== 200) {
throw new RuntimeException($body);
}
$payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
return [
'images' => $payload['data']['images'],
'total' => $payload['data']['total'],
'nextCursor' => $payload['data']['next_cursor'] ?? null,
'headers' => $headers,
];
}
import json
import os
from typing import Any, Optional
import requests
BASE = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
def project_api_key(project_id: str) -> str:
raw = os.environ.get("EPOCHAL_PROJECT_KEYS_JSON", "{}")
mapping = json.loads(raw)
key = mapping.get(project_id) or os.environ.get("EPOCHAL_API_KEY")
if not key:
raise RuntimeError("no Epochal key for project")
return key
def library_search(
project_id: str,
*,
q: str = "",
tags: Optional[str] = None,
limit: int = 24,
cursor: Optional[str] = None,
) -> dict[str, Any]:
params: dict[str, Any] = {
"sort": "relevance" if q else "newest",
"limit": max(1, min(100, limit)),
}
if q:
params["q"] = q[:200]
if tags:
params["tags"] = tags
if cursor:
params["cursor"] = cursor
r = requests.get(
f"{BASE}/images",
params=params,
headers={
"Authorization": f"Bearer {project_api_key(project_id)}",
"Accept": "application/json",
},
timeout=20,
)
# Observe shared budget
remaining = r.headers.get("X-RateLimit-Remaining")
if r.status_code == 429:
raise RuntimeError(f"rate_limit_exceeded retry_after={r.headers.get('Retry-After')}")
r.raise_for_status()
data = r.json()["data"]
return {
"images": data["images"],
"total": data["total"],
"nextCursor": data.get("next_cursor"),
"remaining": remaining,
}
curl -sS "https://epochalstock.com/api/v1/stats" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
curl -sS "https://epochalstock.com/api/v1/tags?sort=count&order=desc&limit=20" \
-H "Authorization: Bearer $EPOCHAL_API_KEY"
async function inventorySnapshot(apiKey) {
const headers = {
Authorization: \`Bearer ${apiKey}\`,
Accept: "application/json",
};
const [statsRes, tagsRes] = await Promise.all([
fetch(\`${base}/stats\`, { headers }),
fetch(\`${base}/tags?sort=count&order=desc&limit=20\`, { headers }),
]);
// Note: 2 authenticated requests — cache aggressively for ops UI
if (!statsRes.ok || !tagsRes.ok) {
throw new Error("inventory_upstream_error");
}
const stats = (await statsRes.json()).data;
const tags = (await tagsRes.json()).data;
return {
totalImages: stats.total_images,
totalTags: stats.total_tags,
catalogBuiltAt: stats.catalog_built_at,
topTags: tags.tags ?? tags, // shape per API envelope
};
}
function inventory_snapshot(string $base, string $apiKey): array
{
$headers = [
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
];
$fetch = static function (string $url) use ($headers): array {
$ch = curl_init($url);
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);
}
return json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR)['data'];
};
$stats = $fetch(rtrim($base, '/') . '/stats');
$tags = $fetch(rtrim($base, '/') . '/tags?' . http_build_query([
'sort' => 'count',
'order' => 'desc',
'limit' => 20,
]));
return [
'totalImages' => $stats['total_images'],
'totalTags' => $stats['total_tags'],
'catalogBuiltAt' => $stats['catalog_built_at'],
'topTags' => $tags,
];
}
def inventory_snapshot(api_key: str) -> dict:
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
stats = requests.get(f"{BASE}/stats", headers=headers, timeout=20)
tags = requests.get(
f"{BASE}/tags",
params={"sort": "count", "order": "desc", "limit": 20},
headers=headers,
timeout=20,
)
stats.raise_for_status()
tags.raise_for_status()
s = stats.json()["data"]
return {
"totalImages": s["total_images"],
"totalTags": s["total_tags"],
"catalogBuiltAt": s["catalog_built_at"],
"topTags": tags.json()["data"],
}
// Single worker process for the whole Epochal account — one concurrent original
const base = process.env.EPOCHAL_API_BASE ?? "https://epochalstock.com/api/v1";
const exportKey = process.env.EPOCHAL_EXPORT_KEY; // named key: agency-export-worker
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function mintAndDownload(imageId, attempt = 1) {
const mint = await fetch(\`${base}/downloads\`, {
method: "POST",
headers: {
Authorization: \`Bearer ${exportKey}\`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ image_id: imageId }),
});
if (mint.status === 409) {
// download_already_active — another job still transferring
if (attempt > 10) throw new Error("lease_busy");
await sleep(Math.min(30_000, 500 * 2 ** (attempt - 1)));
return mintAndDownload(imageId, attempt + 1);
}
if (mint.status === 429) {
await sleep(Number(mint.headers.get("Retry-After") || "5") * 1000);
return mintAndDownload(imageId, attempt + 1);
}
if (!mint.ok) throw new Error(await mint.text());
const { data } = await mint.json();
const file = await fetch(data.download_url); // no API key
if (!file.ok) throw new Error(\`download ${file.status}\`);
return Buffer.from(await file.arrayBuffer());
}
// Drain a Redis/SQS queue one job at a time — never fan out across clients
async function processExportQueue(jobs) {
for (const job of jobs) {
const bytes = await mintAndDownload(job.imageId);
await storePrivate(job.clientId, job.imageId, bytes);
}
}
import os
import time
import requests
BASE = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
EXPORT_KEY = os.environ["EPOCHAL_EXPORT_KEY"]
def mint_and_download(image_id: str, attempt: int = 1) -> bytes:
mint = requests.post(
f"{BASE}/downloads",
json={"image_id": image_id},
headers={
"Authorization": f"Bearer {EXPORT_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
},
timeout=20,
)
if mint.status_code == 409:
if attempt > 10:
raise RuntimeError("lease_busy")
time.sleep(min(30, 0.5 * 2 ** (attempt - 1)))
return mint_and_download(image_id, attempt + 1)
if mint.status_code == 429:
time.sleep(float(mint.headers.get("Retry-After", "5")))
return mint_and_download(image_id, attempt + 1)
mint.raise_for_status()
url = mint.json()["data"]["download_url"]
file_res = requests.get(url, timeout=120) # no Authorization
file_res.raise_for_status()
return file_res.content
def process_export_queue(jobs: list[dict]) -> None:
# One account-wide loop — all clients share the lease
for job in jobs:
data = mint_and_download(job["image_id"])
store_private(job["client_id"], job["image_id"], data)
<?php
// Serial export — one process holds the account download lease
$base = getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1';
$exportKey = getenv('EPOCHAL_EXPORT_KEY');
function mint_and_download(string $base, string $exportKey, string $imageId, int $attempt = 1): string
{
$ch = curl_init(rtrim($base, '/') . '/downloads');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $exportKey,
'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 > 10) {
throw new RuntimeException('lease_busy');
}
usleep((int) (min(30_000, 500 * (2 ** ($attempt - 1)))) * 1000);
return mint_and_download($base, $exportKey, $imageId, $attempt + 1);
}
if ($status === 429) {
sleep(5);
return mint_and_download($base, $exportKey, $imageId, $attempt + 1);
}
if ($status !== 201) {
throw new RuntimeException((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');
}
return (string) $bytes;
}
# Mint then download without the key — run jobs one after another
API_BASE="${EPOCHAL_API_BASE:-https://epochalstock.com/api/v1}"
mint=$(curl -sS -X POST "$API_BASE/downloads" \
-H "Authorization: Bearer $EPOCHAL_EXPORT_KEY" \
-H "Content-Type: application/json" \
-d '{"image_id":"es_001_example"}')
# parse download_url from JSON, then:
# curl -fL "$DOWNLOAD_URL" -o acme/es_001_example.bin
# On 409: wait for the active transfer to finish, then retry mint.
Staff portals still need a backend. An “internal-only” SPA with a live key in env is one repository leak away from total account compromise. SSO the UI; keep secrets on servers.
Do not issue Epochal keys to clients. If a client system must integrate, run a
proxy under contract or create a named key that only your integration service uses—never email
es_live_… to a third party chat thread.
/collections, /upload, GraphQL, or video in v1 catalog (free 1-month trial available via POST /trial/signup)Checklists, management endpoints, subscription states, key hygiene.
30/min headers, 429, 401/402, and retry guidance.
Stats, health, and inventory-oriented monitoring patterns.
Team boards with batch hydration of saved IDs.