developer docs

Guides

Webhooks

Be told when a chapter is done instead of asking: endpoints, signatures and retries.

A webhook endpoint is a URL on your server that tinypica sends a signed POST to when something happens to a chapter: it finished, it failed, or one of its files is ready. It hears about every chapter in the workspace, whether it was uploaded over the API or in the dashboard.

Adding an endpoint

On the API screen, an owner enters the URL and ticks the events it should be sent. A workspace holds up to 5 endpoints. The endpoint's signing secret, whsec_…, is shown once, when it is added: keep it with your server's other secrets.

The URL has to be https, and its name has to lead to public addresses: not localhost, not a private network. It is checked when it is added and again before every delivery. To try an endpoint on your own machine, put a tunnel in front of it.

Test sends a ping at once, and says whether it got a 2xx.

The Webhooks card just after Add endpoint: the signing secret with Copy and Done, the form, and the new endpoint's row with Test and Delete

What arrives

A JSON body of type, timestamp and data, and three headers to check it by:

HTTP
POST /hooks/tinypica HTTP/1.1
Content-Type: application/json
User-Agent: tinypica-webhooks/1
webhook-id: 63daf240-13d6-4f2f-a71e-0519715c296f
webhook-timestamp: 1790599611
webhook-signature: v1,2dg5wSxRuErdnCAdaM/83+INO67bd55xJztixICoGhM=

{"type":"chapter.finished","timestamp":"2026-09-28T12:46:51.500Z","data":{"chapterId":"f56d2008-9c47-4554-9deb-7edc73255de9","projectId":"0cf217a0-c404-4fd7-8732-5f83b9e766ca","position":1,"title":null,"pageCount":18,"languages":[{"languageCode":"en","status":"needs_review","failed":false},{"languageCode":"es","status":"needs_review","failed":false}]}}
HeaderWhat it is
webhook-idThe event's id, the same on every attempt to deliver it. What you tell a repeat by.
webhook-timestampWhen this attempt was sent, in seconds since 1970.
webhook-signaturev1, and the signature, in base64.

What each event's data holds is in the events reference.

Answering

Answer with any 2xx within 10 seconds, then do the work: put the event on a queue of your own rather than downloading a file before you answer. Anything else counts as a failure: another status, no answer in time, and a redirect too, since redirects are not followed.

Retries

A delivery that fails is tried again, eight times in all, over about two days:

AttemptWhen
1At once
21 minute after the first
35 minutes after that
430 minutes after that
52 hours after that
66 hours after that
712 hours after that
824 hours after that, and the last

Every attempt carries the same webhook-id and body, with a timestamp and signature of its own. Deleting an endpoint drops what was still waiting to be sent to it.

Repeats and order

An event can arrive more than once: when your answer was lost on the way back, say. Keep the webhook-ids you have handled, and answer a repeat with a 2xx without doing it again.

Events are not sent in any promised order. A chapter's export.ready can come before its chapter.finished, and a retried event after newer ones. Act on what an event says, and read the chapter with GET /chapters/:id when you need to know where it stands now.

Verifying a delivery

Anyone can POST to your URL, so check each delivery before you trust it. The signature is an HMAC-SHA256 of the id, the timestamp and the body, joined by dots, keyed with the secret: the part after whsec_, decoded from base64.

  1. Refuse a webhook-timestamp more than five minutes from your clock.
  2. Compute the signature over {webhook-id}.{webhook-timestamp}.{body}, with the body exactly as it arrived.
  3. Compare it, in constant time, with each v1, signature in webhook-signature.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody: the request body exactly as it arrived, before any JSON parsing.
export function verify(secret, headers, rawBody) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  // Five minutes either way: an old delivery replayed is refused.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    return false
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64')
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()
  // The header can list several signatures, space-separated.
  return headers['webhook-signature'].split(' ').some((signature) => {
    const given = Buffer.from(signature.replace(/^v1,/, ''), 'base64')
    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}
Python
import base64, hashlib, hmac, time

# raw_body: the request body exactly as it arrived, as bytes.
def verify(secret: str, headers, raw_body: bytes) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    # Five minutes either way: an old delivery replayed is refused.
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(expected, signature.removeprefix("v1,"))
        for signature in headers["webhook-signature"].split(" ")
    )
PHP
// $body: the request body exactly as it arrived, file_get_contents('php://input').
function verify(string $secret, string $id, string $timestamp, string $signatures, string $body): bool
{
    // Five minutes either way: an old delivery replayed is refused.
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $key = base64_decode(substr($secret, strlen('whsec_')));
    $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));
    foreach (explode(' ', $signatures) as $signature) {
        if (hash_equals($expected, preg_replace('/^v1,/', '', $signature))) {
            return true;
        }
    }
    return false;
}

The scheme is Standard Webhooks, so its libraries verify a delivery too, for most languages:

Node.js, with the standardwebhooks package
import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.TINYPICA_WEBHOOK_SECRET)
// Throws when the signature or the timestamp is wrong.
const event = webhook.verify(rawBody, headers)

Verify the body as it arrived. A body parsed as JSON and serialised again is not the same bytes, and its signature will not match.

Whole receivers, that answer at once and download what is announced, are in the examples: Node.js, PHP, Next.js, Nuxt, SvelteKit, Laravel and WordPress.

The log

The API screen lists every delivery of the last 30 days: the event, the endpoint, whether it was delivered, is being retried or was given up on, what your server last answered, and the body it carried. A Test ping is not kept.

The Webhook deliveries log: chapter.finished and export.ready, both delivered on the first attempt, with the chapter.finished body open