developer docs

Languages

Node.js

Upload a chapter and receive its webhooks from Node, with nothing to install.

Two scripts for Node 20 or newer, with nothing to install: fetch, FormData and Blob are built in. Each one was run against the API as it is shown here. Save them as files, and set the key and the secret from the API screen in the environment.

Upload a chapter

Every image in a folder becomes one page of a new chapter, in file-name order. The script starts the run, waits for it, and saves the lettered pages as a zip. It needs a comic project that goes through every step: see downloading files for what other projects have.

upload-chapter.mjs
// Uploads a folder of page images to tinypica as one chapter, waits for it to
// be translated, and saves the lettered pages as a zip.
//
//   TINYPICA_KEY=tp_… node upload-chapter.mjs PROJECT_ID FOLDER [LANGUAGE]
//
// Node 20 or newer, and nothing to install: fetch, FormData and Blob are
// built in. Pages go in file-name order: 001.png, 002.png and so on.
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'

const API = 'https://tinypica.com/api/v1'
const KEY = process.env.TINYPICA_KEY
const [projectId, folder, language = 'en'] = process.argv.slice(2)
const TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

// Every request carries the key. A refusal says why, in statusMessage.
async function api(path, init = {}) {
  const response = await fetch(API + path, { ...init, headers: { ...init.headers, Authorization: `Bearer ${KEY}` } })
  if (!response.ok) {
    const { statusMessage } = await response.json().catch(() => ({}))
    throw new Error(`${init.method ?? 'GET'} ${path}: ${response.status} ${statusMessage ?? response.statusText}`)
  }
  return response
}

// 1. The chapter, empty: staged keeps it waiting for its pages.
const staged = new FormData()
staged.append('staged', '1')
const chapter = await (await api(`/projects/${projectId}/chapters`, { method: 'POST', body: staged })).json()
console.log(`chapter ${chapter.position}: ${chapter.id}`)

// 2. Its pages, one request each. A page sent again with the same position
//    replaces itself, so one that failed is simply sent again.
const pages = (await readdir(folder)).filter(name => TYPES[extname(name).toLowerCase()]).sort()
for (const [index, name] of pages.entries()) {
  const form = new FormData()
  form.append('pages', new Blob([await readFile(join(folder, name))], { type: TYPES[extname(name).toLowerCase()] }), name)
  form.append('hold', '1')
  form.append('position', String(index + 1))
  await api(`/chapters/${chapter.id}/pages`, { method: 'POST', body: form })
}

// 3. The run. Credits are checked here, for every page in every language.
await api(`/chapters/${chapter.id}/start`, { method: 'POST' })

// 4. Wait for the translation. A webhook saves the asking: see webhook.mjs.
let status
do {
  await sleep(10_000)
  status = await (await api(`/chapters/${chapter.id}`)).json()
} while (status.processing)
if (status.failed)
  throw new Error(`the run failed: ${status.failureReason}`)

// 5. The file follows the translation by a few seconds, and is a 409 until then.
for (let attempt = 1; ; attempt++) {
  const response = await fetch(`${API}/chapters/${chapter.id}/export/download?language=${language}&format=zip`, {
    headers: { Authorization: `Bearer ${KEY}` },
  })
  if (response.ok) {
    const file = `chapter-${chapter.position}-${language}.zip`
    await writeFile(file, Buffer.from(await response.arrayBuffer()))
    console.log(`saved ${file}`)
    break
  }
  if (response.status !== 409 || attempt === 60)
    throw new Error(`download: ${response.status} ${(await response.json()).statusMessage}`)
  await sleep(5_000)
}
Shell
TINYPICA_KEY=tp_… node upload-chapter.mjs 0cf217a0-c404-4fd7-8732-5f83b9e766ca ./pages en
Output
chapter 13: f56d2008-9c47-4554-9deb-7edc73255de9
saved chapter-13-en.zip

Receive webhooks

A server that checks each delivery's signature, answers at once, and then downloads the file that an export.ready announces. Serve it over https, behind nginx, Caddy or a tunnel, and add that URL as an endpoint on the API screen.

webhook.mjs
// Receives tinypica's webhooks, and downloads every file they announce.
//
//   TINYPICA_KEY=tp_… TINYPICA_WEBHOOK_SECRET=whsec_… node webhook.mjs
//
// Node 20 or newer, and nothing to install. Serve it over https (behind nginx,
// Caddy or a tunnel) and add that URL as an endpoint on the API screen.
import { createHmac, timingSafeEqual } from 'node:crypto'
import { writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { basename } from 'node:path'

const API = 'https://tinypica.com/api/v1/'
const KEY = process.env.TINYPICA_KEY
const SECRET = Buffer.from(process.env.TINYPICA_WEBHOOK_SECRET.slice('whsec_'.length), 'base64')
const PORT = 8000

// An event is sent again until it is answered, and an answer can be lost on
// the way back. Keep these in your database, not in memory.
const handled = new Set()

// Standard Webhooks: an HMAC-SHA256 of "id.timestamp.body", keyed with the secret.
function verify(headers, body) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  // Five minutes either way, so an old delivery cannot be replayed.
  if (!id || !(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300))
    return false
  const expected = createHmac('sha256', SECRET).update(`${id}.${timestamp}.${body}`).digest()
  return String(headers['webhook-signature'] ?? '').split(' ').some((signature) => {
    const given = Buffer.from(signature.replace(/^v1,/, ''), 'base64')
    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}

async function handle(event) {
  if (event.type === 'export.ready') {
    const { downloadUrl, filename } = event.data
    // Your key goes to tinypica and nowhere else.
    if (!downloadUrl.startsWith(API))
      return
    const response = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${KEY}` } })
    if (!response.ok)
      throw new Error(`download ${filename}: ${response.status}`)
    await writeFile(basename(filename), Buffer.from(await response.arrayBuffer()))
    console.log(`saved ${filename}`)
  }
  else if (event.type === 'chapter.failed') {
    console.error(`chapter ${event.data.position} failed: ${event.data.failureReason}`)
  }
}

createServer(async (request, response) => {
  const chunks = []
  for await (const chunk of request)
    chunks.push(chunk)
  // The body exactly as it arrived: parsed and serialised again, it would
  // no longer match its signature.
  const body = Buffer.concat(chunks).toString('utf8')
  if (request.method !== 'POST' || !verify(request.headers, body)) {
    response.writeHead(401).end()
    return
  }
  // Answer first: tinypica waits 10 seconds, then counts it as failed.
  response.writeHead(204).end()
  const id = request.headers['webhook-id']
  if (handled.has(id))
    return
  handled.add(id)
  handle(JSON.parse(body)).catch(error => console.error(error))
}).listen(PORT, () => console.log(`listening on http://localhost:${PORT}`))
Shell
TINYPICA_KEY=tp_… TINYPICA_WEBHOOK_SECRET=whsec_… node webhook.mjs

The ids it has handled live in memory, so a restart forgets them. Keep them in your database, since an event is sent again until your server answers it.

With Express

The same verify works in an Express route. The one thing to get right is the body: take it raw, as express.json() would already have parsed it.

JavaScript
import express from 'express'

const app = express()
// express.json() would parse the body before it can be verified: take it raw.
app.post('/hooks/tinypica', express.raw({ type: 'application/json' }), (request, response) => {
  const body = request.body.toString('utf8')
  if (!verify(request.headers, body))
    return response.sendStatus(401)
  response.sendStatus(204)
  // …then handle JSON.parse(body), as webhook.mjs does
})

How deliveries are signed, retried and ordered is in webhooks. In an app built on a framework, see Next.js, Nuxt or SvelteKit.