developer docs

Frameworks

Next.js

A route for webhooks, and a page that uploads a chapter with a Server Action.

An App Router app with two additions: a route that tinypica's webhooks are sent to, and a page that uploads a chapter with a Server Action. Both were run in a Next.js 16 app, against the API, before they were put here.

The client

A module of plain fetch and Web Crypto, with nothing to install. It holds your key, so it is imported by server code only: routes, Server Actions, Server Components.

lib/tinypica.ts
// tinypica's API from server code: Next.js, Nuxt, SvelteKit, or anything else
// with fetch and Web Crypto (Node 20+, Deno, Bun, Cloudflare Workers). Nothing
// to install. Server-side only, since it is handed your key.

const API = 'https://tinypica.com/api/v1'

export class TinypicaError extends Error {
  constructor(readonly status: number, message: string) {
    super(message)
  }
}

// One request. A refusal throws, with the reason tinypica gave.
export async function tinypica<T>(key: string, path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(API + path, { ...init, headers: { ...init.headers, Authorization: `Bearer ${key}` } })
  if (!response.ok) {
    const { statusMessage } = await response.json().catch(() => ({}))
    throw new TinypicaError(response.status, statusMessage ?? response.statusText)
  }
  return response.json() as Promise<T>
}

// A new chapter from its pages, in reading order, with its run started. Each
// page is a File or Blob whose type is an image's: a file from a form has one.
export async function uploadChapter(key: string, projectId: string, pages: Blob[]) {
  const staged = new FormData()
  staged.append('staged', '1')
  const chapter = await tinypica<{ id: string, position: number }>(key, `/projects/${projectId}/chapters`, { method: 'POST', body: staged })
  for (const [index, page] of pages.entries()) {
    const form = new FormData()
    form.append('pages', page)
    form.append('hold', '1')
    form.append('position', String(index + 1))
    await tinypica(key, `/chapters/${chapter.id}/pages`, { method: 'POST', body: form })
  }
  await tinypica(key, `/chapters/${chapter.id}/start`, { method: 'POST' })
  return chapter
}

// A file an export.ready announced. Your key goes to tinypica and nowhere else.
export async function downloadExport(key: string, downloadUrl: string): Promise<ArrayBuffer> {
  if (!downloadUrl.startsWith(`${API}/`))
    throw new Error(`not a tinypica download: ${downloadUrl}`)
  const response = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${key}` } })
  if (!response.ok)
    throw new TinypicaError(response.status, `download failed: ${response.status}`)
  return response.arrayBuffer()
}

// No return type written: inferred, it is the ArrayBuffer-backed kind that
// Web Crypto takes under every TypeScript version.
function bytes(base64: string) {
  return Uint8Array.from(atob(base64), char => char.charCodeAt(0))
}

// Whether a delivery is tinypica's (Standard Webhooks): an HMAC-SHA256 of
// "id.timestamp.body", keyed with the endpoint's secret. `body` is the request
// body exactly as it arrived: parsed and serialised again, it would not match.
export async function verifyWebhook(secret: string, headers: Headers, body: string): Promise<boolean> {
  const id = headers.get('webhook-id')
  const timestamp = headers.get('webhook-timestamp')
  // Five minutes either way, so an old delivery cannot be replayed.
  if (!id || !timestamp || !(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300))
    return false
  const key = await crypto.subtle.importKey('raw', bytes(secret.slice('whsec_'.length)), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
  const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`)
  for (const signature of (headers.get('webhook-signature') ?? '').split(' ')) {
    try {
      if (await crypto.subtle.verify('HMAC', key, bytes(signature.replace(/^v1,/, '')), signed))
        return true
    }
    catch {
      // Not base64: not a signature of ours.
    }
  }
  return false
}

// What a delivery carries (docs: /docs/api/events).
export interface WebhookEvent {
  type: 'chapter.finished' | 'chapter.failed' | 'export.ready' | 'ping'
  timestamp: string
  data: Record<string, any>
}

The key and the endpoint's signing secret, from the API screen:

.env.local
TINYPICA_KEY=tp_…
TINYPICA_WEBHOOK_SECRET=whsec_…

Receive webhooks

A route handler reads the body as text, checks its signature, and answers. The download runs in after(), once the answer is out. Add https://your-site.example/api/tinypica as an endpoint on the API screen.

app/api/tinypica/route.ts
// tinypica's webhooks. The endpoint to add on the API screen is
// https://your-site.example/api/tinypica
import { writeFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { after } from 'next/server'
import { downloadExport, verifyWebhook, type WebhookEvent } from '@/lib/tinypica'

export async function POST(request: Request) {
  // The body as it arrived: the signature is over these bytes.
  const body = await request.text()
  if (!await verifyWebhook(process.env.TINYPICA_WEBHOOK_SECRET!, request.headers, body))
    return new Response(null, { status: 401 })

  const event: WebhookEvent = JSON.parse(body)
  // After the answer, which tinypica waits 10 seconds for. An event can come
  // twice: keep the webhook-ids you have handled in your database.
  after(async () => {
    if (event.type === 'export.ready') {
      const file = await downloadExport(process.env.TINYPICA_KEY!, event.data.downloadUrl)
      // To disk here; to S3, your CMS or wherever your chapters live.
      await writeFile(basename(event.data.filename), Buffer.from(file))
    }
  })
  return new Response(null, { status: 204 })
}

On Vercel and other serverless hosts the disk does not last: put the file in object storage, or in whatever holds your chapters.

Upload a chapter

A form with a Server Action. The files arrive as Files with their types, which is what the upload routes take.

app/chapters/new/page.tsx
// A page for your team that uploads a chapter, with a Server Action. It spends
// your credits: put it behind the sign-in your other admin pages have.
import { redirect } from 'next/navigation'
import { uploadChapter } from '@/lib/tinypica'

async function upload(form: FormData) {
  'use server'
  // Pages in file-name order: 1.png, 2.png, …, 10.png.
  const pages = (form.getAll('pages') as File[])
    .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
  const chapter = await uploadChapter(process.env.TINYPICA_KEY!, String(form.get('project')), pages)
  redirect(`/chapters/new?started=${chapter.position}`)
}

export default async function NewChapter({ searchParams }: { searchParams: Promise<{ started?: string }> }) {
  const { started } = await searchParams
  return (
    <form action={upload}>
      {started && <p>Chapter {started} is running.</p>}
      <input name="project" placeholder="Project id" required />
      <input name="pages" type="file" accept="image/png,image/jpeg,image/webp" multiple required />
      <button type="submit">Upload</button>
    </form>
  )
}

A Server Action takes 1 MB by default, so raise its limit to what a chapter weighs:

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    // A Server Action takes 1 MB by default, and one chapter's pages are more.
    serverActions: { bodySizeLimit: '200mb' },
  },
}

export default nextConfig

How deliveries are signed, retried and ordered is in webhooks.