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.
// 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.
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.
// 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:
npm i -D @types/nodeUpload a chapter
A form action that hands the form's files on to tinypica, and the page it answers.
// 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
<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:
BODY_SIZE_LIMIT=200M node buildHow deliveries are signed, retried and ordered is in webhooks.