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.
// 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:
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.
// 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.
// 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:
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.