Plugin authors
Teams building panels for canvas tools who need licensed search without embedding vendor secrets in distributable packages.
App blueprint · Design plugin
Ship an in-editor stock panel for Figma-style tools, browser design editors, or desktop plugins: search the full catalog, inspect metadata, place watermarked previews on the canvas, and pull full-resolution originals only when the user exports—always through your backend, never with a Bearer key in the plugin bundle. One plan: $50/month, 30 requests/minute shared across keys, unlimited serial original downloads, one concurrent transfer.
This page is a product and engineering playbook—not a host-specific plugin SDK. It maps UI surfaces to documented catalog endpoints so you can implement the same pattern in any design host that can call your HTTPS API.
Teams building panels for canvas tools who need licensed search without embedding vendor secrets in distributable packages.
Web products with an “Insert stock image” rail that must stay key-safe under CSP and public JavaScript inspection.
Internal design systems that wrap Epochal Stock for staff plugins and shared export pipelines.
Browser-only demos that call the catalog with a live key, free-tier experiments, video stock,
or inventing /collections / GraphQL on Epochal.
Keep the first release focused. Each feature below maps to a documented endpoint or to data you already store; none require undocumented APIs.
q, optional tag chips, sort, “Load more”
via opaque cursor (GET /images).
thumbnail_url / title / dimensions;
store stable id on each card.
GET /images/{id}).
preview_url or
thumbnail_url only (no original, no API key on the client).
POST /downloads and transfers originals serially.
GET /tags
with image_count.
EPOCHAL_API_KEY.
Plan facts: $50 USD/month (first month $40 with the 20% upgrade discount) ·
full image catalog · 30 authenticated req/min per account (all keys share it) · up to 20 named
keys · unlimited original downloads · one concurrent original transfer ·
free 1-month trial (2,000 requests at 2/min via POST /trial/signup) · no credits or video in v1.
Every catalog call follows the same rule used across this documentation site: Browser / Plugin UI → Your Backend → Epochal API. The Bearer key exists only on the backend.
Plugin never holds EPOCHAL_API_KEY
Do not put es_live_… in plugin source, manifest.json,
browser extension storage, or client env files shipped to users. Catalog CORS with secret keys is
not a supported public-browser pattern. Query-string keys are rejected
(query_key_rejected).
Wire each UI surface to the smallest documented call. Prefer batch later only if you hydrate many saved IDs at once (see the moodboard blueprint).
| Plugin screen / action | Your API (example) | Epochal endpoint | Notes |
|---|---|---|---|
| Search box, filters, Load more | GET /api/stock/search |
GET /images |
q, tags, tag_mode, sort, limit, cursor |
| Open detail drawer | GET /api/stock/images/:id |
GET /images/{id} |
Title, tags, dimensions, preview URLs |
| Tag chip suggestions | GET /api/stock/tags |
GET /tags |
Optional; sort by count |
| Place on canvas | No Epochal call | — | Use preview_url / thumbnail_url already returned |
| Export package / final file | POST /api/stock/export |
POST /downloads then GET signed URL |
Backend only; serial; handle 409 |
| Catalog size badge (ops) | GET /api/stock/stats |
GET /stats |
Optional admin strip |
Full parameter tables: Search & list, Detail & batch, Downloads, Tags & stats.
The search panel is the primary surface. Debounce typing (300–500 ms), cap free text at 200 characters, and forward only whitelisted query parameters to Epochal through your proxy.
qSearches title, description, and tags. With q set, default sort is relevance.
tags + tag_modeUp to 20 tags. all requires every chip; any matches at least one.
sortrelevance, newest, or oldest for browse vs keyword modes.
limit + cursorPrefer 24–48 for grids. Pass next_cursor back unchanged for “Load more”.
data.total when useful; disable Load more when next_cursor is absentinvalid_cursor, reset to page one with the same filtersmeta.request_id / X-Request-ID for support tickets
When the designer selects a card, open a drawer fed by
GET https://epochalstock.com/api/v1/images/{image_id}
(proxied). Display title, description, dimensions, file extension, tags, and a larger watermarked
preview. Do not expose original filesystem paths—the API never returns them.
Selection → detail proxy → drawer
Cache detail responses briefly on your server if the same ID is reopened often, but treat preview URLs as temporary display assets—re-fetch if the host expires or blocks them.
“Place” should feel instant. Use the preview_url (watermarked) or
thumbnail_url already present on the search or detail payload. That placement does
not require POST /downloads and must not pull a secret key into the plugin.
id on the canvas node for later export mapping
When the user exports a design package or downloads full-resolution assets, your backend runs the
documented two-step flow: mint a five-minute, single-use token with
POST /downloads, then GET the signed download_url
without the API key. Serialize transfers—the account allows only
one concurrent original. On 409 download_already_active, wait and retry.
Export queue · serial originals
Deep dive: Safe original export pipeline and Original downloads.
Example: proxy “golden forest” with tags nature,trees, tag_mode=all,
sort newest, limit 30. The plugin calls your route; only the server talks to
https://epochalstock.com/api/v1/images.
# Server-side only — never from the plugin
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"
// Express-style search proxy for the design plugin
import express from "express";
const app = express();
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 server");
app.get("/api/stock/search", async (req, res) => {
// Assume requireAuth(req) already ran
const q = String(req.query.q ?? "").slice(0, 200);
const tags = String(req.query.tags ?? "").slice(0, 500);
const tagMode = req.query.tag_mode === "any" ? "any" : "all";
const sort = ["relevance", "newest", "oldest"].includes(String(req.query.sort))
? String(req.query.sort)
: q ? "relevance" : "newest";
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
const cursor = req.query.cursor ? String(req.query.cursor) : "";
const params = new URLSearchParams({ sort, limit: String(limit), tag_mode: tagMode });
if (q) params.set("q", q);
if (tags) params.set("tags", tags);
if (cursor) params.set("cursor", cursor);
const upstream = await fetch(\`${base}/images?${params}\`, {
headers: {
Authorization: \`Bearer ${apiKey}\`,
Accept: "application/json",
},
});
const text = await upstream.text();
if (!upstream.ok) {
res.status(upstream.status).type("application/json").send(text);
return;
}
const body = JSON.parse(text);
// Return only what the plugin needs
res.json({
images: body.data.images,
total: body.data.total,
nextCursor: body.data.next_cursor ?? null,
requestId: body.meta?.request_id ?? upstream.headers.get("x-request-id"),
});
});
<?php
declare(strict_types=1);
// GET /api/stock/search — server-side proxy for the design plugin
$base = rtrim(getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1', '/');
$key = getenv('EPOCHAL_API_KEY');
if ($key === false || $key === '') {
http_response_code(500);
echo json_encode(['error' => 'server_misconfigured']);
exit;
}
$q = substr((string) ($_GET['q'] ?? ''), 0, 200);
$tags = substr((string) ($_GET['tags'] ?? ''), 0, 500);
$tagMode = (($_GET['tag_mode'] ?? 'all') === 'any') ? 'any' : 'all';
$sortIn = (string) ($_GET['sort'] ?? '');
$sort = in_array($sortIn, ['relevance', 'newest', 'oldest'], true)
? $sortIn
: ($q !== '' ? 'relevance' : 'newest');
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 30)));
$cursor = (string) ($_GET['cursor'] ?? '');
$params = [
'sort' => $sort,
'limit' => $limit,
'tag_mode' => $tagMode,
];
if ($q !== '') {
$params['q'] = $q;
}
if ($tags !== '') {
$params['tags'] = $tags;
}
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 ' . $key,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$response = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($status === 200 ? 200 : $status);
header('Content-Type: application/json');
if ($status !== 200) {
echo (string) $response;
exit;
}
$payload = json_decode((string) $response, true, 512, JSON_THROW_ON_ERROR);
echo json_encode([
'images' => $payload['data']['images'] ?? [],
'total' => $payload['data']['total'] ?? 0,
'nextCursor' => $payload['data']['next_cursor'] ?? null,
'requestId' => $payload['meta']['request_id'] ?? null,
], JSON_THROW_ON_ERROR);
# FastAPI-style search proxy
import os
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException, Query
app = FastAPI()
BASE = os.environ.get("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").rstrip("/")
API_KEY = os.environ["EPOCHAL_API_KEY"]
@app.get("/api/stock/search")
async def stock_search(
q: str = Query("", max_length=200),
tags: str = "",
tag_mode: str = "all",
sort: Optional[str] = None,
limit: int = Query(30, ge=1, le=100),
cursor: Optional[str] = None,
):
tag_mode = "any" if tag_mode == "any" else "all"
if sort not in (None, "relevance", "newest", "oldest"):
sort = None
if sort is None:
sort = "relevance" if q else "newest"
params = {"sort": sort, "limit": limit, "tag_mode": tag_mode}
if q:
params["q"] = q
if tags:
params["tags"] = tags
if cursor:
params["cursor"] = cursor
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(
f"{BASE}/images",
params=params,
headers={
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json",
},
)
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail=r.text)
body = r.json()
data = body["data"]
return {
"images": data["images"],
"total": data["total"],
"nextCursor": data.get("next_cursor"),
"requestId": body.get("meta", {}).get("request_id"),
}
curl -sS "https://epochalstock.com/api/v1/images/es_001_example" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Accept: application/json"
app.get("/api/stock/images/:id", async (req, res) => {
const id = encodeURIComponent(req.params.id);
const upstream = await fetch(\`${base}/images/${id}\`, {
headers: {
Authorization: \`Bearer ${apiKey}\`,
Accept: "application/json",
},
});
const text = await upstream.text();
res.status(upstream.status).type("application/json").send(text);
});
$id = rawurlencode((string) ($_GET['id'] ?? ''));
$ch = curl_init($base . '/images/' . $id);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($status);
header('Content-Type: application/json');
echo (string) $body;
@app.get("/api/stock/images/{image_id}")
async def stock_detail(image_id: str):
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 != 200:
raise HTTPException(status_code=r.status_code, detail=r.text)
return r.json()
Keys stay server-side. Plugin marketplaces, browser extensions, and desktop
installers are public. Anyone can unpack your binary and extract an embedded
es_live_… secret. Put the key in a secrets manager on your backend only.
Authenticate plugin traffic to you. Your /api/stock/* routes must
require your own user/session auth. An open proxy turns your $50 plan into a public open
relay and burns the shared 30 req/min budget for every stranger.
q (200) and tag count (20) yourself—do not rely only on upstream validation/upload, or client-side catalog keys in v1
The account limit is 30 authenticated requests per minute, shared across all named
keys (max 20). Creating plugin-prod and plugin-staging does not double
the budget.
| Traffic | Counts toward 30/min? | Guidance |
|---|---|---|
GET /images (each page) |
Yes · 1 | Debounce search; cache short-TTL identical queries |
GET /images/{id} |
Yes · 1 | Cache drawer payloads; avoid re-fetch on every hover |
POST /downloads |
Yes · 1 per mint | Export worker only; serialize with concurrency lease |
Binary GET of download_url |
No catalog unit | Holds the single concurrent transfer until complete |
| Plugin loading preview images | No | CDN/media URLs—not catalog API calls |
Headers and 429 behavior: Rate limits & errors.
design-plugin-prodEPOCHAL_API_KEY only on the backend / workerDeeper GET /images parameters, pagination, and rate notes.
Two-step downloads, serial queues, and 409 handling.
Save IDs in your DB and hydrate with POST /images/batch.
Named keys per project under a shared 30/min account budget.