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.

What arrives
A JSON body of type, timestamp and data, and three headers to check it by:
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}]}}| Header | What it is |
|---|---|
webhook-id | The event's id, the same on every attempt to deliver it. What you tell a repeat by. |
webhook-timestamp | When this attempt was sent, in seconds since 1970. |
webhook-signature | v1, 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:
| Attempt | When |
|---|---|
| 1 | At once |
| 2 | 1 minute after the first |
| 3 | 5 minutes after that |
| 4 | 30 minutes after that |
| 5 | 2 hours after that |
| 6 | 6 hours after that |
| 7 | 12 hours after that |
| 8 | 24 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.
- Refuse a
webhook-timestampmore than five minutes from your clock. - Compute the signature over
{webhook-id}.{webhook-timestamp}.{body}, with the body exactly as it arrived. - Compare it, in constant time, with each
v1,signature inwebhook-signature.
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)
})
}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(" ")
)// $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:
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.
