Guides / Tutorials
Cache and self-host dog breed images
By the Dog Image API team. Updated .
Full-size files are PNG, 2048 pixels square, and each one counts once against your monthly plan. Download them from a server, save each under a key made of breed, variant and view, and serve that copy from your own storage or CDN. Pages then never call the API.
The images are AI-generated illustrations, and each file is 1 to 7 MB. That size is one more reason to keep a copy instead of asking the API every time.
Why download once
Three facts decide the design.
- The full-size endpoint needs your API key, and the key must never reach a browser or an app.
- A plan counts distinct full-size images per calendar month, in UTC. The same image fetched again in the same month is free, but a month later it is a fresh count if you ask again.
- The API is built for downloading, not for serving your visitors. Do not hot-link it in pages.
So you fetch on a schedule or on demand, from one place, and keep what you get. Files fetched while a plan was active stay usable in what you built with them, as the license says.
The storage key
Mirror the API path. One file is one view of one set.
dog/{breed}/{variant}/{view}.png
dog/pug/fawn/front.png
dog/golden-retriever/dark/head.png
The variant is the color slug, or the word default for a breed's main set. Using the same words as the API means a missing file can be named from the URL alone, and your fetch code needs no lookup table. The breed pages show which variants exist.
What to download
Decide the list first. A set is nine views, so a set costs nine images. On 2026-11-14 the catalog had 329 sets, which is 2,961 images. A Starter plan of 200 images covers 22 full sets, and a Pro plan of 700 covers 77. The All images plan covers every full-size image. See pricing for the current plans.
Most products need a short list: the breeds that appear in the product, and often one or two views each. Put that list in a file and sync only it. The live catalog is at GET /api/v1/breeds, and its views array holds the nine view ids.
A sync job in Node
This script needs Node 18 or later and no packages. It skips files that exist, retries the two retryable errors and stops when the quota is used up.
// sync.mjs (run on a server or in CI, never in a browser)
import { mkdir, writeFile, access } from 'node:fs/promises';
import path from 'node:path';
const API = 'https://dogimage.dev/api/v1';
const KEY = process.env.DOG_IMAGE_API_KEY;
const OUT = process.env.IMAGE_DIR ?? './dog';
// breed, variant, views you want. Use 'default' for the main set.
const WANTED = [
{ breed: 'pug', variant: 'default', views: ['front', 'left', 'head'] },
{ breed: 'beagle', variant: 'default', views: ['front', 'left', 'head'] },
];
const exists = (p) => access(p).then(() => true, () => false);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function download(breed, variant, view) {
const file = path.join(OUT, breed, variant, `${view}.png`);
if (await exists(file)) return 'skipped';
const url = `${API}/images/${breed}/${variant}/${view}?size=full`;
for (let attempt = 1; attempt <= 3; attempt++) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if (res.status === 502 || res.status === 503) {
await sleep(1000 * attempt); // not counted, retry
continue;
}
if (res.status === 429) throw new Error('quota_used_up');
if (!res.ok) throw new Error(`${res.status} for ${url}: ${await res.text()}`);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, Buffer.from(await res.arrayBuffer()));
console.log(`saved ${file} (remaining: ${res.headers.get('X-Quota-Remaining')})`);
return 'saved';
}
throw new Error(`gave up on ${url}`);
}
for (const { breed, variant, views } of WANTED) {
for (const view of views) {
await download(breed, variant, view);
}
}
Run it with DOG_IMAGE_API_KEY=... node sync.mjs. The X-Quota-Remaining header says how many images are left, and it reads unlimited on the All images plan.
The same job in Python
# sync.py (needs: pip install requests)
import os
import time
from pathlib import Path
import requests
API = "https://dogimage.dev/api/v1"
KEY = os.environ["DOG_IMAGE_API_KEY"]
OUT = Path(os.environ.get("IMAGE_DIR", "./dog"))
WANTED = [
("samoyed", "default", ["front", "right", "head"]),
("poodle", "default", ["front", "right", "head"]),
]
def download(breed: str, variant: str, view: str) -> None:
target = OUT / breed / variant / f"{view}.png"
if target.exists():
return
url = f"{API}/images/{breed}/{variant}/{view}"
for attempt in range(1, 4):
res = requests.get(
url, params={"size": "full"}, headers={"Authorization": f"Bearer {KEY}"}, timeout=60
)
if res.status_code in (502, 503):
time.sleep(attempt)
continue
if res.status_code == 429:
raise SystemExit("Monthly quota is used up.")
res.raise_for_status()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(res.content)
print("saved", target, res.headers.get("X-Quota-Remaining"))
return
raise RuntimeError(f"gave up on {url}")
for breed, variant, views in WANTED:
for view in views:
download(breed, variant, view)
Both scripts do the same four things: skip what you have, retry the errors the API marks as retryable, stop on a used-up quota, and write the file under the storage key.
Serve from your side
Upload the folder to object storage and put a CDN in front, or serve it from your own web server. A given storage key always holds the same file, so you can set a long cache time on your copy.
Cache-Control: public, max-age=31536000, immutable
Content-Type: image/png
If you replace a file later, give it a new key rather than overwriting the old one. Your pages and apps then point at https://img.example.com/dog/pug/fawn/front.png, which is yours.
When hot-linking is fine
Previews are different. The 512 and 1024 pixel WebP files may be hot-linked, they are served with a one-year immutable cache header, and a visible credit link next to the image or on the same page covers the license. For a landing page or a prototype, that is the simplest route. Use full-size files, stored on your side, when you need 2048 pixels, PNG, print or packaging, or when you want no credit line.
Running the numbers
Re-running the job is safe. Existing files are skipped, so a second run costs nothing. The count only grows when you add a new image to WANTED. A failed fetch (502) is not counted either. If you change plans or the month turns, you still keep every file you already have.
Check the quota at any time with GET /api/v1/usage, which needs the key and returns the plan, the limit and what is left. For the API in full, read the docs.
Questions
Why not call the full-size endpoint from my pages?
The full-size files are meant to be downloaded once and stored on your side. Calling the API from pages would also need a key in the browser, and a key must stay on a server.
Does fetching the same image twice use two images of my plan?
Not in the same calendar month (UTC). A plan counts distinct full-size images per month, so a repeat fetch in that month is free. Storing the file means you never need to repeat it.
Can I hot-link the free previews?
Yes. Previews may be hot-linked, with a visible credit link next to the image or on the same page. Full-size files may not be hot-linked.
Can I publish the stored files as my own image library?
No. The license allows use in your own products, but not reselling, sublicensing or republishing the images as a collection, dataset or image library. Read the license page for the exact terms.
Next steps
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3