Key in the browser
SPA or plugin code that embeds es_live_… can be extracted from bundles.
Authenticated catalog CORS is not a supported public-browser pattern
(cors_not_supported).
Use case · Original export
Design tools, print pipelines, and agency libraries often need full-resolution files—not only
thumbnails and watermarked previews. The Developer Image API solves that with a
two-step download flow: mint a five-minute, single-use
download_url on your server, then fetch the binary without the
Bearer API key. This page is the product-oriented playbook for building a reliable, key-safe export queue.
Shipping originals is easy to get wrong. A naive client that calls the catalog with a browser-held key, or that appends secrets to download URLs, leaks credentials into places you cannot scrub: browser history, extension stores, CDN and reverse-proxy access logs, mobile crash dumps, and third-party analytics beacons.
SPA or plugin code that embeds es_live_… can be extracted from bundles.
Authenticated catalog CORS is not a supported public-browser pattern
(cors_not_supported).
Query-string secrets on catalog traffic are rejected (query_key_rejected).
Signed download URLs already authorize a single transfer—do not bolt a key onto them.
Users often export multi-select galleries at once. The account allows
one concurrent original transfer. Parallel mints return
409 download_already_active.
Tokens last about five minutes and are single-use. Minting far ahead of the binary GET, or logging the URL for “later,” burns tokens and expands the leak surface.
Goal of this use case: Your product accepts a list of catalog
image_id values from a trusted user session, enqueues them on a backend worker,
mints and redeems download tokens serially, and either stores files in your private storage
or streams them to the user through your own origin—never through a browser-held API key.
The rule is constant across every use case on this site: the application backend holds the
API key. UIs, plugins, and mobile clients talk only to your API. Your server talks to
https://epochalstock.com/api/v1.
Keys stay on the server · binaries may stream through it
EPOCHAL_API_KEY in a secrets manager or environment variable on the worker host only.image_id.download_url.Catalog endpoints return metadata, thumbnails, and previews only. Full originals use the documented download pair described on Original downloads.
Create token → fetch binary · five minutes · single-use
POST https://epochalstock.com/api/v1/downloads with
Authorization: Bearer …,
Content-Type: application/json, and
{"image_id":"…"}. Success is HTTP 201 with a signed
download_url (typically under a data envelope).
GET to that URL immediately. Do not send the API key
as a header or query parameter. Write the body to private storage or stream it through your origin.
POST https://epochalstock.com/api/v1/downloads
Authorization: Bearer $EPOCHAL_API_KEY
Content-Type: application/json
Accept: application/json
{"image_id":"es_001_example"}
# Then, without the key:
GET $DOWNLOAD_URL
→ original image bytes
Never put API keys on download URLs. The signature in the URL is the only
credential required for the binary transfer. Adding ?api_key=… or a Bearer header
is unnecessary and can leak secrets into CDN and proxy logs.
Accounts may run one concurrent original transfer. All named API keys on the account share that lease. Serial downloads are otherwise unlimited—there is no per-download credit balance in v1. Design your export feature as a single worker (or a mutex per account) that processes one image at a time.
Enqueue job rows (image_id, destination path, user id). A single consumer
mints, downloads, marks complete, then takes the next row.
After transfer N finishes and the lease is free, start N+1. Throughput is limited by file size and network—not a hard download count cap.
Fan-out across threads or lambdas against the same account will hit
409 download_already_active. Extra keys do not buy parallel leases.
Create the token only when the worker is ready to download. A five-minute window is plenty for an immediate GET, not for overnight queues of pre-minted URLs.
Queue · lock · mint · GET · unlock · repeat
download_url—it is single-use.409 download_already_active
If any process on the account is already transferring an original, further attempts to start
another return HTTP 409 with error code download_already_active.
This is expected under concurrency—not a permanent failure.
| Situation | What to do |
|---|---|
| Your own worker is mid-transfer | Do not mint again for a second job. Wait until the first GET completes, then continue serially. |
| Another service/key on the same account is exporting | Backoff and retry. Keys share one lease—coordinate or schedule exports. |
| Stale client assumed parallel was fine | Catch 409, re-queue with delay/jitter, surface “export in progress” in the UI. |
| Hung transfer held the lease | Ensure timeouts and connection cleanup so the lease is released; then retry mint. |
Integration tip: Prefer preventing 409s with a local mutex over relying on remote conflicts as your primary scheduler. Still handle 409 with exponential backoff in case a second deployment or staging key competes for the same account lease.
// Pseudocode for a resilient export step
function exportOne(imageId):
for attempt in 1..N:
response = POST /downloads { image_id: imageId } // Bearer key
if response.status == 201:
GET response.data.download_url → save bytes // no API key
return ok
if response.error.code == "download_already_active": // HTTP 409
sleep(backoff(attempt) + jitter)
continue
if response.status == 429:
sleep(Retry-After or until X-RateLimit-Reset)
continue
fail permanently (401, 402, 404 original_unavailable, …)
fail: max retries exceeded
Authenticated catalog traffic shares 30 requests per minute per account. Creating more keys does not multiply the budget or the download lease.
POST /downloads counts toward the 30 req/min budget.
GET on download_url does not consume an
extra catalog rate-limit request, but it holds the concurrency lease until the transfer finishes.
429 / rate_limit_exceeded, honor Retry-After (or wait
until X-RateLimit-Reset) with jitter—do not tight-loop mint calls.
POST /images/batch
(up to 100 ids) so metadata work burns fewer of the 30 requests before export starts.
Full header reference and retry strategy: Rate limits & errors.
Serial export of several image IDs: mint a five-minute token, download without the API key,
handle 409 download_already_active, then proceed to the next id. Load the key from
the environment only. Base URL: https://epochalstock.com/api/v1.
# Mint (Bearer required) then download (no key). Repeat serially per image.
API_BASE="${EPOCHAL_API_BASE:-https://epochalstock.com/api/v1}"
mint="$(curl -sS -X POST "$API_BASE/downloads" \
-H "Authorization: Bearer $EPOCHAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"image_id":"es_001_example"}')"
# Parse data.download_url from the 201 JSON, then:
curl -fL "$DOWNLOAD_URL" -o "es_001_example.jpg"
# On HTTP 409 download_already_active: wait and retry the mint step.
# Do not run two mints+GETs in parallel on the same 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;
if (!apiKey) throw new Error("Set EPOCHAL_API_KEY");
const ids = ["es_001_example", "es_002_example"];
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function exportOne(imageId, 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 transferring
if (attempt >= 8) throw new Error("lease busy too long");
const wait = Math.min(30_000, 500 * 2 ** (attempt - 1)) + Math.random() * 250;
await sleep(wait);
return exportOne(imageId, attempt + 1);
}
if (mint.status === 429) {
const retryAfter = Number(mint.headers.get("Retry-After") || "5");
await sleep(retryAfter * 1000);
return exportOne(imageId, 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(`${imageId}.bin`, Buffer.from(await fileRes.arrayBuffer()));
}
// Serial only — do not Promise.all exportOne calls on one account
for (const id of ids) {
await exportOne(id);
}
<?php
declare(strict_types=1);
$base = getenv('EPOCHAL_API_BASE') ?: 'https://epochalstock.com/api/v1';
$apiKey = getenv('EPOCHAL_API_KEY');
if (!$apiKey) {
throw new RuntimeException('Set EPOCHAL_API_KEY');
}
/**
* @return void
*/
function export_one(string $base, string $apiKey, string $imageId, 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) {
// download_already_active
if ($attempt >= 8) {
throw new RuntimeException('lease busy too long');
}
usleep((int) ((500 * (2 ** ($attempt - 1))) + random_int(0, 250)) * 1000);
export_one($base, $apiKey, $imageId, $attempt + 1);
return;
}
if ($status === 429) {
sleep(5);
export_one($base, $apiKey, $imageId, $attempt + 1);
return;
}
if ($status !== 201) {
throw new RuntimeException('mint failed: ' . (string) $raw);
}
$downloadUrl = json_decode((string) $raw, true, 512, JSON_THROW_ON_ERROR)['data']['download_url'];
$ch = curl_init($downloadUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
]);
$bytes = curl_exec($ch);
$dlStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($dlStatus !== 200) {
throw new RuntimeException('download failed: ' . $dlStatus);
}
file_put_contents($imageId . '.bin', $bytes);
}
// Serial loop — never fan out concurrent originals for one account
foreach (['es_001_example', 'es_002_example'] as $id) {
export_one($base, $apiKey, $id);
}
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"]
ids = ["es_001_example", "es_002_example"]
def export_one(image_id: 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:
# download_already_active — serialize and retry
if attempt >= 8:
raise RuntimeError("lease busy too long")
time.sleep(min(30, 0.5 * (2 ** (attempt - 1))) + random.random() * 0.25)
return export_one(image_id, attempt + 1)
if mint.status_code == 429:
retry_after = int(mint.headers.get("Retry-After", "5"))
time.sleep(retry_after)
return export_one(image_id, attempt + 1)
mint.raise_for_status()
download_url = mint.json()["data"]["download_url"]
# Binary GET — no API key
with requests.get(download_url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(f"{image_id}.bin", "wb") as f:
for chunk in r.iter_content(chunk_size=256 * 1024):
if chunk:
f.write(chunk)
# Serial only
for image_id in ids:
export_one(image_id)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"math/rand"
"net/http"
"os"
"time"
)
func exportOne(base, apiKey, imageID string, attempt int) error {
body, _ := json.Marshal(map[string]string{"image_id": imageID})
req, _ := http.NewRequest(http.MethodPost, base+"/downloads", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode == http.StatusConflict { // 409 download_already_active
if attempt >= 8 {
return fmt.Errorf("lease busy too long")
}
wait := time.Duration(math.Min(30, 0.5*math.Pow(2, float64(attempt-1)))*1000) * time.Millisecond
time.Sleep(wait + time.Duration(rand.Intn(250))*time.Millisecond)
return exportOne(base, apiKey, imageID, attempt+1)
}
if res.StatusCode == http.StatusTooManyRequests {
time.Sleep(5 * time.Second)
return exportOne(base, apiKey, imageID, attempt+1)
}
if res.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(res.Body)
return fmt.Errorf("mint %d: %s", res.StatusCode, b)
}
var payload struct {
Data struct {
DownloadURL string `json:"download_url"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return err
}
dlClient := &http.Client{Timeout: 120 * time.Second}
dl, err := dlClient.Get(payload.Data.DownloadURL) // no API key
if err != nil {
return err
}
defer dl.Body.Close()
out, err := os.Create(imageID + ".bin")
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, dl.Body)
return err
}
func main() {
base := os.Getenv("EPOCHAL_API_BASE")
if base == "" {
base = "https://epochalstock.com/api/v1"
}
apiKey := os.Getenv("EPOCHAL_API_KEY")
for _, id := range []string{"es_001_example", "es_002_example"} {
if err := exportOne(base, apiKey, id, 1); err != nil {
panic(err)
}
}
}
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var baseUrl = Environment.GetEnvironmentVariable("EPOCHAL_API_BASE")
?? "https://epochalstock.com/api/v1";
var apiKey = Environment.GetEnvironmentVariable("EPOCHAL_API_KEY")
?? throw new InvalidOperationException("Set EPOCHAL_API_KEY");
async Task ExportOneAsync(string imageId, int attempt = 1)
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
using var mintReq = new HttpRequestMessage(
HttpMethod.Post,
baseUrl.TrimEnd('/') + "/downloads");
mintReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
mintReq.Headers.Accept.ParseAdd("application/json");
mintReq.Content = new StringContent(
JsonSerializer.Serialize(new { image_id = imageId }),
Encoding.UTF8,
"application/json");
using var mintRes = await client.SendAsync(mintReq);
if (mintRes.StatusCode == HttpStatusCode.Conflict) // 409 download_already_active
{
if (attempt >= 8) throw new InvalidOperationException("lease busy too long");
var ms = Math.Min(30_000, 500 * (int)Math.Pow(2, attempt - 1)) + Random.Shared.Next(0, 250);
await Task.Delay(ms);
await ExportOneAsync(imageId, attempt + 1);
return;
}
if ((int)mintRes.StatusCode == 429)
{
await Task.Delay(TimeSpan.FromSeconds(5));
await ExportOneAsync(imageId, attempt + 1);
return;
}
mintRes.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await mintRes.Content.ReadAsStringAsync());
var downloadUrl = doc.RootElement.GetProperty("data").GetProperty("download_url").GetString()!;
using var dlClient = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
using var fileRes = await dlClient.GetAsync(downloadUrl); // no API key
fileRes.EnsureSuccessStatusCode();
await using var fs = File.Create(imageId + ".bin");
await fileRes.Content.CopyToAsync(fs);
}
// Serial only
foreach (var id in new[] { "es_001_example", "es_002_example" })
{
await ExportOneAsync(id);
}
require "json"
require "net/http"
require "uri"
base = ENV.fetch("EPOCHAL_API_BASE", "https://epochalstock.com/api/v1").sub(%r{/+\z}, "")
api_key = ENV.fetch("EPOCHAL_API_KEY")
def export_one(base, api_key, image_id, attempt = 1)
uri = URI("#{base}/downloads")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{api_key}"
req["Content-Type"] = "application/json"
req["Accept"] = "application/json"
req.body = { image_id: image_id }.to_json
mint = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
if mint.code.to_i == 409
# download_already_active
raise "lease busy too long" if attempt >= 8
sleep([30, 0.5 * (2 ** (attempt - 1))].min + rand * 0.25)
return export_one(base, api_key, image_id, attempt + 1)
end
if mint.code.to_i == 429
sleep 5
return export_one(base, api_key, image_id, attempt + 1)
end
raise "mint failed: #{mint.code}" unless mint.code.to_i == 201
download_url = JSON.parse(mint.body).dig("data", "download_url")
dl_uri = URI(download_url)
Net::HTTP.start(dl_uri.host, dl_uri.port, use_ssl: true) do |http|
resp = http.get(dl_uri.request_uri) # no API key
raise "download failed: #{resp.code}" unless resp.code.to_i == 200
File.binwrite("#{image_id}.bin", resp.body)
end
end
# Serial only
%w[es_001_example es_002_example].each { |id| export_one(base, api_key, id) }
Endpoint details, error table, and security notes for downloads alone: Original downloads. Broader multi-language samples: Code examples.
Treat download_url as a secret until it expires or is used.
Anyone with the URL can retrieve the full original within the five-minute, single-use window.
Do not paste it into chat, tickets, public issue trackers, or client-side analytics.
Never put the Bearer key in a browser, plugin bundle, or download URL. Architecture is always: app backend holds the key → mints tokens → fetches or proxies binaries. Public browsers are not supported clients for authenticated catalog endpoints.
EPOCHAL_API_KEY.X-Request-ID when mint fails so support can investigate without secrets.Key lifecycle, portal CSRF, subscription states, and the management surface: Security & billing.
Before you ship multi-image export
image_id list from your product session, not anonymous callers.409 download_already_active with backoff; handle 429 with Retry-After.image_not_found, original_unavailable, subscription errors.| HTTP | Code | Export meaning |
|---|---|---|
201 |
— | Token created; download immediately without the API key. |
401 |
invalid_api_key / invalid_download_token |
Fix credentials or mint a fresh token. |
402 |
subscription_required / subscription_past_due |
Resolve billing before exporting. |
404 |
image_not_found / original_unavailable |
Skip or surface to the user; do not infinite-retry. |
409 |
download_already_active |
Wait for the active transfer; retry with backoff. |
429 |
rate_limit_exceeded |
Honor Retry-After; space mint calls. |
Canonical two-step reference, concurrency rules, and download error table.
Key storage, safe proxy architecture, portal session, and plan states.
Resolve IDs and metadata before minting download tokens.
Shared 30 req/min budget, headers, and full error reference.