developer docs

Frameworks

SvelteKit

An endpoint for webhooks, and a page that uploads a chapter with a form action.

SvelteKit 2 with Svelte 5: an endpoint that tinypica's webhooks are sent to, and a page that uploads a chapter with a form action. Both were run in a SvelteKit app, against the API, before they were put here.

The client

A module of plain fetch and Web Crypto, with nothing to install. Under $lib/server, SvelteKit refuses to let it reach the browser, key and all.

src/lib/server/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, read through $env/static/private. Use $env/dynamic/private instead when they are set at run time rather than at build.

.env
TINYPICA_KEY=tp_…
TINYPICA_WEBHOOK_SECRET=whsec_…

Receive webhooks

The endpoint reads the body as text, checks its signature, and answers; the download runs on after it. Add https://your-site.example/api/tinypica as an endpoint on the API screen.

src/routes/api/tinypica/+server.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 { TINYPICA_KEY, TINYPICA_WEBHOOK_SECRET } from '$env/static/private'
import { downloadExport, verifyWebhook, type WebhookEvent } from '$lib/server/tinypica'
import type { RequestHandler } from './$types'

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

  // After the answer, which tinypica waits 10 seconds for: on a Node server a
  // promise nobody awaits runs on. An event can come twice: keep the
  // webhook-ids you have handled in your database.
  handle(JSON.parse(body)).catch(error => console.error('tinypica webhook', error))
  return new Response(null, { status: 204 })
}

async function handle(event: WebhookEvent) {
  if (event.type === 'export.ready') {
    const file = await downloadExport(TINYPICA_KEY, event.data.downloadUrl)
    // To disk here; to S3, your CMS or wherever your chapters live.
    await writeFile(basename(event.data.filename), new Uint8Array(file))
  }
}

It writes to disk, for adapter-node. A new project needs Node's types for that, and on a serverless host the file belongs in object storage instead:

Shell
npm i -D @types/node

Upload a chapter

A form action that hands the form's files on to tinypica, and the page it answers.

src/routes/chapters/new/+page.server.ts
// A page for your team that uploads a chapter. It spends your credits: put it
// behind the sign-in your other admin pages have.
import { TINYPICA_KEY } from '$env/static/private'
import { uploadChapter } from '$lib/server/tinypica'
import type { Actions } from './$types'

export const actions = {
  default: async ({ request }) => {
    const form = await request.formData()
    // 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(TINYPICA_KEY, String(form.get('project')), pages)
    return { started: chapter.position }
  },
} satisfies Actions
src/routes/chapters/new/+page.svelte
<script lang="ts">
  let { form } = $props()
</script>

<form method="POST" enctype="multipart/form-data">
  {#if form?.started}
    <p>Chapter {form.started} is running.</p>
  {/if}
  <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>

adapter-node takes 512 KB in a request by default, less than most pages. Raise it where the server starts:

Shell
BODY_SIZE_LIMIT=200M node build

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