Skip to content

TypeScript examples

Snippets target TypeScript 5.4+ on Node.js 22+ (native fetch, top-level await). They work unchanged in Deno and Bun. For the browser, drop the node: imports.

const API = 'https://api.photopick.cz/api/v1';
const TOKEN = process.env.PHOTOPICK_API_KEY!; // pp_live_xxxxxxxxxxxxxxxx
const authHeaders = { Authorization: `Bearer ${TOKEN}` } as const;

A small typed wrapper that narrows Response to your payload and throws on non-2xx:

interface RateLimit { limit: number; remaining: number; reset: number }
interface Me {
apiKeyId: number
customerId: number
name: string
scopes: string[]
rateLimit: RateLimit
}
async function pp<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API}${path}`, {
...init,
headers: { ...authHeaders, ...(init.headers ?? {}) },
});
if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
return res.status === 204 ? (undefined as T) : (res.json() as Promise<T>);
}
const me = await pp<Me>('/me');
console.log(me.scopes, me.rateLimit.remaining);

Required scope: customer:read.

Typed async generator — constant memory, every yielded value is Photo:

interface Photo {
id: number
filename: string
bytes: number
takenAt: string | null
tagIds: number[]
}
interface Page<T> {
data: T[]
pagination: { nextCursor: string | null; hasMore: boolean }
}
async function* iteratePhotos(pageSize = 100): AsyncGenerator<Photo> {
let cursor: string | undefined
for (;;) {
const url = new URL(`${API}/photos`)
url.searchParams.set('limit', String(pageSize))
if (cursor) url.searchParams.set('cursor', cursor)
const page = await pp<Page<Photo>>(`/photos?${url.searchParams}`)
for (const p of page.data) yield p
if (!page.pagination.hasMore || !page.pagination.nextCursor) return
cursor = page.pagination.nextCursor
}
}
for await (const photo of iteratePhotos()) {
console.log(photo.id, photo.filename)
}

Required scope: photos:read.

import { writeFile } from 'node:fs/promises'
async function downloadPhoto(photoId: number, outPath: string): Promise<Photo> {
const meta = await pp<Photo>(`/photos/${photoId}`)
const res = await fetch(`${API}/photos/${photoId}/download`, {
headers: authHeaders,
redirect: 'follow',
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await writeFile(outPath, new Uint8Array(await res.arrayBuffer()))
return meta
}
const meta = await downloadPhoto(1234, './photo-1234.jpg')
console.log(`Saved ${meta.filename} (${meta.bytes} bytes)`)

Required scope: photos:read.

interface Tag { id: number; name: string; color: string | null }
// Create
const created = await pp<Tag>('/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Selects', color: '#e94b60' }),
})
// Attach (full replace — pass the complete tag list)
await pp<void>(`/photos/1234/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagIds: [created.id] }),
})
// Rename
await pp<Tag>(`/tags/${created.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Final selects' }),
})
// Delete
await pp<void>(`/tags/${created.id}`, { method: 'DELETE' })

Required scopes: tags:write (create/rename/delete), photos:write (attach), tags:read (list).

Generic retry that preserves the typed return value of the wrapped call:

async function withRetry<T>(
call: () => Promise<Response>,
{ maxAttempts = 5 }: { maxAttempts?: number } = {},
): Promise<Response> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await call()
if (res.ok || (res.status < 500 && res.status !== 429)) return res
const retryAfter = Number(res.headers.get('retry-after') ?? attempt)
const backoff = Math.min(retryAfter, 2 ** attempt)
await new Promise(r => setTimeout(r, backoff * 1000))
}
throw new Error('retry budget exhausted')
}
const res = await withRetry(() =>
fetch(`${API}/photos`, { headers: authHeaders }),
)
const body = (await res.json()) as Page<Photo>

See Rate limits and Errors for the full vocabulary.