Skip to main content

Webhooks

Webhooks notify your application when events occur in Typograph. Instead of polling for status changes, configure webhook endpoints to receive real-time notifications.

Webhook Management API​

Manage your webhook subscriptions programmatically using the Webhook Service API. An AI agent can do the same through the MCP server's webhook tools.

Required scopes: webhook (full access), webhook:read, webhook:write, webhook:delete.

Token type: All /v1/webhook/* endpoints require a user token — subscriptions are owned by a user within an organization. Client Credentials tokens are rejected. See Token Types.

A subscription or delivery {id} in a path that names nothing of your organization's, or is not a UUID, answers 404 Not Found (not_found).

List Subscriptions​

curl "https://api.typograph.nl/v1/webhook/subscriptions?limit=20&offset=0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Add client_id=<uuid> to list only the subscriptions of one OAuth client. A limit over 50, or a client_id that is not a UUID, is refused with 400 Bad Request (invalid_request).

Response: list entries are a summary view: they omit organization_id and client_id, which GET /subscriptions/{id} does return. No read ever includes the secret, and endpoint header values are masked (***) on every read; see Create Subscription.

{
"data": [
{
"id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"name": "Production pipeline",
"endpoint": {
"url": "https://your-app.com/webhooks/typograph",
"headers": { "X-Tenant-Id": "***" }
},
"event_types": ["publication.completed", "conversion.completed"],
"is_active": true,
"failure_count": 0,
"last_failure_at": null,
"created_at": "<ISO 8601 timestamp>",
"updated_at": "<ISO 8601 timestamp>"
}
],
"meta": {
"total": 1,
"limit": 20,
"offset": 0,
"has_more": false
}
}

See Pagination for the limit / offset contract.

Create Subscription​

curl -X POST https://api.typograph.nl/v1/webhook/subscriptions \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production pipeline",
"client_id": "019b28fb-a11e-7641-a28f-e978f892ec01",
"endpoint": {
"url": "https://your-app.com/webhooks/typograph",
"headers": { "X-Tenant-Id": "tenant-123" }
},
"event_types": ["publication.completed", "publication.failed"]
}'

Request fields:

FieldTypeRequiredDescription
namestring (≤ 255)yesHuman-readable subscription name
client_idUUID v7yesOAuth client of your organization the subscription belongs to; it cannot be changed later
endpoint.urlURL (≤ 2000)yesHTTPS endpoint that will receive deliveries
endpoint.headersobjectnoUp to 5 custom headers sent with every delivery
event_typesarray, ≥ 1 entryyesEvent types to subscribe to (see below)

Response (201 Created): the full subscription, including organization_id, client_id, the raw secret, and the same secret as standard_webhooks_secret for the official Standard Webhooks libraries — this is the only time the full secret is shown. Store it now; it cannot be retrieved again. Every later read (GET /subscriptions/{id}, GET /subscriptions, PATCH) returns the subscription without either secret field, and masks endpoint.headers values as ***. If you lose the secret, call Rotate Secret to get a new one.

The secret is a raw string, used as the HMAC key exactly as it is. standard_webhooks_secret is the same key in the whsec_ format of the official Standard Webhooks libraries: pass that one to a library. See Secret Format.

A client_id that is not a client of your organization is refused with 403 Forbidden (access_denied), the same whether the client belongs to another organization or does not exist. If the client cannot be checked at that moment, the request is refused with 503 Service Unavailable; send it again.

Get Subscription​

curl https://api.typograph.nl/v1/webhook/subscriptions/{id} \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Update Subscription​

curl -X PATCH https://api.typograph.nl/v1/webhook/subscriptions/{id} \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated name",
"endpoint": {
"url": "https://your-app.com/webhooks/v2/typograph",
"headers": { "X-Tenant-Id": "tenant-123" }
},
"event_types": ["publication.completed"],
"is_active": true
}'

All PATCH fields are optional. Send only the fields you want to change.

Endpoint header values come back masked as *** on every read. A header you send with the value *** keeps its stored value, so you can read a subscription, change one field and send the endpoint back without overwriting the real header values. Sending *** for a header that has no stored value is a 400. To remove every custom header, send the endpoint with an empty headers object: "endpoint": { "url": "https://your-app.com/webhooks/typograph", "headers": {} }.

Rotate Secret​

curl -X POST https://api.typograph.nl/v1/webhook/subscriptions/{id}/rotate-secret \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response (200 OK): the subscription with a freshly generated secret and its standard_webhooks_secret — again, the only time they are shown. Use this if a secret has leaked, or periodically as a best practice.

For 24 hours after a rotate, deliveries are signed with both the new and the previous secret: the webhook-signature header carries two space-separated v1,<signature> values. Verify against either one during that window (the helper in Signature Verification already loops over them), then drop the old secret once you have switched. After 24 hours only the new secret signs deliveries.

A second rotate inside those 24 hours is refused with 409 Conflict ("error": "rotation_in_progress", with the time the overlap ends in error_description), so the secret your endpoint verifies with is never dropped while you are still switching.

A leaked secret: rotate with revoke_previous=true to stop every earlier secret at once. This is accepted inside the 24-hour window too, so a secret that leaks right after a routine rotate can still be dropped immediately. There is no overlap: from then on only the new secret signs, so deliveries fail at your endpoint until it verifies with the new secret.

curl -X POST "https://api.typograph.nl/v1/webhook/subscriptions/{id}/rotate-secret?revoke_previous=true" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Delete Subscription​

curl -X DELETE https://api.typograph.nl/v1/webhook/subscriptions/{id} \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Returns 204 No Content on success.

List Available Events​

curl https://api.typograph.nl/v1/webhook/events \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Returns the current event-type catalog — use this as the authoritative list when validating user input in your own UI.

Available Event Types​

Only the event types emitted by the Typograph backend can be subscribed to. The current catalog is:

Event typeTrigger
publication.completedA publisher (PDF/image generation) job finished successfully
publication.failedA publisher job failed
conversion.completedA converter (InDesign / font / image conversion) job finished successfully
conversion.failedA converter job failed
template.updatedA template was renamed, moved, restored, or saved from the editor
template.deletedA template was moved to the trash
form.updatedA form's fields, steps, linked templates or status changed
form.deletedA form was deleted, leaving its templates with no way to be filled in
project.deletedA project was moved to the trash, taking every design in it
subscription.upgradedThe organization moved to a higher plan
subscription.downgradedThe organization's move to a lower plan took effect
subscription.cancelledThe organization's subscription was cancelled
subscription.reactivatedA cancelled subscription was reactivated

The job events reach the subscriptions of the client that started the job, within the organization the job ran for: another organization's subscription never receives them. The design events reach every subscription of the organization that owns the design, because the change is usually made by a designer in the portal while the integration that needs to hear about it is somewhere else entirely. The subscription events reach the organization whose plan changed, for the same reason.

Webhook Delivery​

When an event fires, the webhook service POSTs the event payload to endpoint.url.

HTTP Request​

PropertyValue
MethodPOST
Content-Typeapplication/json
Timeout30 seconds
TLSRequired (HTTPS endpoints only)

Headers​

Every delivery includes Standard Webhooks signature headers:

HeaderDescriptionExample
webhook-idUnique delivery identifier (UUID)019b28fb-a11e-7641-a28f-e978f892ec06
webhook-timestampUnix epoch seconds at send time1704672000
webhook-signatureHMAC-SHA256 signature, format v1,{base64}; two space-separated values for 24 hours after a secret rotationv1,K3Z8mHdf...
webhook-event-typeThe event type of this deliverytemplate.updated
Content-Typeapplication/json

Plus any custom headers you configured on endpoint.headers (up to 5). The headers above are protected — custom headers with those names are ignored.

Request Body​

The request body is the raw event payload — no envelope, no wrapper. The event type is not in the body: read it from the webhook-event-type header.

publication.completed / publication.failed​

The body is the publisher job snapshot plus identifiers, without the headers of any of its URLs:

{
"id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"job_id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"client_id": "019b28fb-a11e-7641-a28f-e978f892ec01",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec04",
"tag": "invoice-alpha",
"parent_id": null,
"status": "completed",
"message": null,
"error_code": null,
"page_count": 4,
"input": { "template": { "url": "..." }, "manifest": { "url": "..." } },
"output": {
"upload": { "url": "..." },
"config": { "type": "pdf", "single_pages": false, "rotate_pages": [{ "page": 2, "degrees": 90 }] }
},
"expires_at": "<ISO 8601 timestamp>",
"created_at": "<ISO 8601 timestamp>",
"updated_at": "<ISO 8601 timestamp>"
}

The file is where you sent it: output.upload.url. When the job ended is updated_at. output.config.rotate_pages is the same {page, degrees} list the job was created with, null when it turns no page.

page_count is the number of pages in the file the job delivered: the pages of a PDF, 1 for an image, and for a merge the pages of all its rows together. It is null for a multi-page image job, whose pages are jobs of their own that each send their own event with page_count: 1.

For publication.failed, the payload additionally includes an error field with the failure reason, status: "failed", and error_code, a stable code for the reason, to act on or to show the failure in your own language. message and error are written for people and may change; the code does not. Show error for a code you do not know: more may be added.

page_count and error_code are in the webhook payload only: GET /v1/publisher/jobs/{id} does not return them, and the same holds for error_code and the converter's jobs.

error_codeThe job failed because
download_faileda file of the job (template, manifest, bundle or CSV) could not be downloaded
file_too_largea file of the job is larger than the limit
invalid_inputthe bundle or the template is not what a job takes: not JSON, or not a JSON object
invalid_merge_datathe CSV of a merge cannot fill the template: unreadable, empty, or not matching its fields
row_limit_exceededthe CSV of a merge has more rows than your plan merges
plan_unavailableyour plan could not be checked, so the merge was not started; try again
layout_unavailablethe template had to be laid out again, and the layout service did not answer; try again
layout_failedthe template had to be laid out again, and its pages could not be built
fonts_unavailablethe template had to be laid out again, and its fonts did not all load
text_overflowlaid out again, the text no longer fits its frames and would print cut off
generation_failedthe PDF or image could not be generated
merge_row_faileda row of the merge did not print, so the merge delivers nothing; the report names the rows
upload_failedthe result could not be uploaded to output.upload.url
stalledthe job stopped in a step and did not continue

A job with a CSV sends one event, for the parent, once every row ended: its rows send none of their own. Its payload carries row_count, printed_row_count, failed_row_count and the report of the rows that did not print; see Following a merge.

conversion.completed / conversion.failed​

The body is the converter job snapshot plus identifiers, without the headers of any of its URLs:

{
"id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"job_id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"client_id": "019b28fb-a11e-7641-a28f-e978f892ec01",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec04",
"tag": "font-conversion-001",
"status": "completed",
"message": null,
"error_code": null,
"warnings": [],
"input": { "type": "font", "url": "..." },
"output": { "type": "woff2", "upload": { "url": "..." } },
"expires_at": "<ISO 8601 timestamp>",
"created_at": "<ISO 8601 timestamp>",
"updated_at": "<ISO 8601 timestamp>"
}

For conversion.failed, the payload includes an error field, status: "failed", and error_code, a stable code for the reason (null in conversion.completed). As for a publication, act on the code and show error for one you do not know.

error_codeThe job failed because
download_failedthe input file could not be downloaded
conversion_failedthe input could not be converted
document_not_builtthe converted package could not be built into pages, so there is no template to deliver
upload_failedthe result could not be uploaded to output.upload.url
stalledthe job stopped in a step and did not continue

A conversion.completed for a package conversion can carry a message: the template was converted, but its text was not laid out (a font missing from the package, for example). See Text Layout. warnings lists what the conversion could not carry over, one {code, message, ...} per item, and is [] when there is nothing; see Warnings.

A package conversion whose converted template cannot be opened, so that no page can be built from it, fails rather than deliver a blank template: conversion.failed, with the reason in message and in error, and error_code: "document_not_built". See Text Layout.

template.updated / template.deleted​

Enough to find the design again and decide what to do; the design itself is fetched through the API as usual.

{
"template_id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"project_id": "019b28fb-a11e-7641-a28f-e978f892ec02",
"team_id": "019b28fb-a11e-7641-a28f-e978f892ec03",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec04",
"name": "Welkom",
"updated_at": "<ISO 8601 timestamp>",
"reason": "saved"
}

reason is saved when the editor wrote a new version, renamed for a metadata change, restored when a deleted template came back, and moved when it changed project. A moved event also carries previous_project_id, because a move can put the design outside what your app key is allowed to read — which is a different problem from the design having changed. template.deleted carries the same body without reason.

Opening the editor does not fire template.updated — only a write does.

form.updated / form.deleted​

{
"form_id": "019b28fb-a11e-7641-a28f-e978f892ec07",
"project_id": "019b28fb-a11e-7641-a28f-e978f892ec02",
"team_id": "019b28fb-a11e-7641-a28f-e978f892ec03",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec04",
"name": "Personaliseren",
"status": "published",
"template_ids": ["019b28fb-a11e-7641-a28f-e978f892ec06"],
"updated_at": "<ISO 8601 timestamp>"
}

template_ids names the designs this form fills in. That link is the one thing that can change without either the form or the design changing, so linking a form to a different template arrives here as a form.updated whose template_ids moved — nothing else in the payload would give it away.

status is what decides whether the form can still be filled in at all: anything other than published means a visitor gets a form with nothing in it. form.deleted carries the same body, and means the same thing permanently.

project.deleted​

{
"project_id": "019b28fb-a11e-7641-a28f-e978f892ec02",
"team_id": "019b28fb-a11e-7641-a28f-e978f892ec03",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec04",
"name": "Kerst 2026",
"updated_at": "<ISO 8601 timestamp>"
}

A project in the trash takes every design in it, and no template.deleted is emitted for any of them — the project goes, not the templates one by one. If you keep references to designs, this is the only word you get that a whole shelf of them just became unreadable.

Restoring a project emits nothing. Neither does an app key being revoked, a scope being narrowed, or a delivery being retried past the point where it is given up on — which is why an integration that must not sell what it cannot make should reconcile on a schedule as well as listen.

A form going from draft to published arrives as an update like any other, so an integration that only sells published forms can act on status.

subscription.upgraded, subscription.downgraded, subscription.cancelled, subscription.reactivated​

{
"subscription_id": "019b28fb-a11e-7641-a28f-e978f892ec05",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec04",
"plan_code": "free",
"plan_name": "Free",
"status": "active",
"billing_cycle": "monthly",
"previous_plan_code": "pro"
}

For an integration that needs a particular plan: a webshop that runs on Pro (pro) and up has to hear the moment an organization drops below it. previous_plan_code is present on upgrades and downgrades. The plan codes are free, pro, enterprise and internal.

EventSent whenstatus
subscription.upgradedthe new plan takes effect, at onceactive
subscription.downgradedthe lower plan takes effect, at the end of the period it was scheduled foractive
subscription.cancelledsomebody cancels: the cancellation is scheduled, not donecancelled
subscription.reactivateda cancelled subscription is taken back before it endsactive

A cancelled subscription keeps its plan until the end of the period it was paid for; nothing is sent when that period runs out. To know when access really ends, read current_period_ends_at from GET /v1/subscription/me.

A downgrade is sent when it takes effect — at the end of the billing period it was scheduled for — not when somebody schedules it, because until then the organization still has the plan it paid for. To read the plan itself, call GET /v1/subscription/me with a client token that has subscription:read; the payload names a plan code, and what that code allows is the plan's to say.

See the Publisher and Converter references for the complete job schemas.

Success Criteria​

A delivery is considered successful when your endpoint returns HTTP 200-299. Anything else (3xx, 4xx, 5xx, network error, timeout) is a failure and triggers the retry schedule.

Retry Behavior​

Failed deliveries retry with an exponential backoff:

AttemptDelayTotal elapsed
1 (initial)immediate0
21 min1 min
35 min6 min
415 min21 min
51 hr1 hr 21 min
62 hr3 hr 21 min

After 6 total attempts the delivery is marked permanently failed. Its delivery history shows the HTTP status your endpoint answered (response_status) and why the last attempt failed (error_message, error_code).

Automatic Subscription Disable​

A subscription is automatically disabled after 10 consecutive failed deliveries across any events. When disabled, is_active flips to false and no further deliveries are attempted until you re-enable it:

curl -X PATCH https://api.typograph.nl/v1/webhook/subscriptions/{id} \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_active": true}'

A successful delivery resets the failure counter, and so does re-enabling a disabled subscription: it is disabled again only after another 10 consecutive failed deliveries.

Signature Verification​

Always verify signatures before processing a webhook.

note

The secret used here is the per-subscription webhook signing secret returned by POST /subscriptions and POST /subscriptions/{id}/rotate-secret — it is not your OAuth application/client secret. Each subscription has its own, and no later read ever returns it again.

For 24 hours after a rotate, webhook-signature may carry two space-separated v1,<signature> values (new secret first, previous secret second). Try each one in turn; see Rotate Secret.

Secret Format​

The secret is a 64-character hex string, and the string itself is the HMAC-SHA256 key: sign with its characters as they are. Do not hex-decode it into 32 bytes first. The examples on this page do exactly that, so with them you need nothing else.

This is not the secret format of the Standard Webhooks specification. Its official standardwebhooks libraries expect whsec_ followed by the base64 of the key bytes, and they base64-decode whatever comes after the prefix. A hex string is valid base64, so passing our secret to them as it is gives no error. It gives a different key, and every delivery fails with No matching signature found.

With the official Standard Webhooks libraries​

Create and rotate return the secret in their format as well, as standard_webhooks_secret: whsec_ followed by the base64 of the secret string. It is the same key, so the signatures match. Pass it to the library as it is.

For a secret you stored without it, make it yourself:

library_secret = "whsec_" + base64(secret)
LanguageConversion
Node.js'whsec_' + Buffer.from(secret).toString('base64')
Python'whsec_' + base64.b64encode(secret.encode()).decode()
PHP'whsec_' . base64_encode($secret)

Each library also takes the raw secret directly, if you say it is raw: new Webhook(secret, { format: 'raw' }) in JavaScript, Webhook(secret.encode()) in Python (bytes are used as the key as they are, a string is read as whsec_), and Webhook::fromRaw($secret) in PHP.

The examples below convert a stored secret. If you stored standard_webhooks_secret, pass it to new Webhook(...) as it is instead.

const { Webhook } = require('standardwebhooks');

const wh = new Webhook('whsec_' + Buffer.from(process.env.TYPOGRAPH_WEBHOOK_SECRET).toString('base64'));

// Throws when the signature or the timestamp does not check out.
const payload = wh.verify(rawBody, {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
});
import base64, os
from standardwebhooks import Webhook

secret = os.environ['TYPOGRAPH_WEBHOOK_SECRET']
wh = Webhook('whsec_' + base64.b64encode(secret.encode()).decode())

# Raises WebhookVerificationError when the signature or the timestamp does not check out.
payload = wh.verify(raw_body, {
'webhook-id': headers['webhook-id'],
'webhook-timestamp': headers['webhook-timestamp'],
'webhook-signature': headers['webhook-signature'],
})
use StandardWebhooks\Webhook;

$wh = new Webhook('whsec_' . base64_encode($webhookSecret));

// Throws WebhookVerificationException when the signature or the timestamp does not check out.
$payload = $wh->verify($rawBody, [
'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'],
'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'],
'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'],
]);

The libraries check the 5-minute timestamp window themselves. During a rotation's overlap they accept either of the two signatures, so the rotation needs nothing extra from you. Pass the raw request body, not a re-encoded JSON object: re-encoding changes the bytes that were signed.

Signature Construction​

message   = "{webhook-id}.{webhook-timestamp}.{raw-body}"
signature = base64(hmac_sha256(message, secret)) # key = the secret string as is
header = "v1," + signature

Verification Steps​

  1. Extract webhook-id, webhook-timestamp, and webhook-signature from the request headers.
  2. Reject if |now - webhook-timestamp| > 300 seconds (prevents replay).
  3. Recompute the signature using your stored secret.
  4. Compare against each space-separated value in webhook-signature using a timing-safe comparison — there may be two during a rotation's overlap window.

Node.js​

const crypto = require('crypto');
const TOLERANCE_SECONDS = 300;

function verifyWebhookSignature(webhookId, timestamp, rawBody, signatureHeader, secret) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) {
return false;
}

const expected = crypto
.createHmac('sha256', secret)
.update(`${webhookId}.${timestamp}.${rawBody}`)
.digest('base64');
const expectedBuf = Buffer.from(expected);

// signatureHeader may carry two space-separated "v1,<sig>" values during a
// secret rotation's 24-hour overlap window; accept either one.
return signatureHeader.split(' ').some((entry) => {
const received = entry.startsWith('v1,') ? entry.slice(3) : entry;
const receivedBuf = Buffer.from(received);
return receivedBuf.length === expectedBuf.length && crypto.timingSafeEqual(receivedBuf, expectedBuf);
});
}

app.post('/webhooks/typograph', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhookSignature(
req.headers['webhook-id'],
req.headers['webhook-timestamp'],
req.body.toString(),
req.headers['webhook-signature'],
WEBHOOK_SECRET,
);

if (!ok) {
return res.status(401).json({ error: 'Invalid signature' });
}

const payload = JSON.parse(req.body);
// payload.job_id, payload.status, etc. — process the event

res.status(200).json({ received: true });
});

PHP​

<?php

const TOLERANCE_SECONDS = 300;

function verifyWebhookSignature(
string $webhookId,
string $timestamp,
string $rawBody,
string $signatureHeader,
string $secret,
): bool {
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}

$expected = base64_encode(hash_hmac('sha256', "{$webhookId}.{$timestamp}.{$rawBody}", $secret, true));

// $signatureHeader may carry two space-separated "v1,<sig>" values during a
// secret rotation's 24-hour overlap window; accept either one.
foreach (explode(' ', $signatureHeader) as $entry) {
$received = str_starts_with($entry, 'v1,') ? substr($entry, 3) : $entry;
if (hash_equals($expected, $received)) {
return true;
}
}

return false;
}

$rawBody = file_get_contents('php://input');
$webhookId = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';

if (!verifyWebhookSignature($webhookId, $timestamp, $rawBody, $signature, $webhookSecret)) {
http_response_code(401);
exit('Invalid signature');
}

$payload = json_decode($rawBody, true);
// ... process $payload
http_response_code(200);

Delivery History​

View past deliveries for a subscription:

curl "https://api.typograph.nl/v1/webhook/subscriptions/{id}/deliveries?limit=20&offset=0" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Each delivery entry includes its status, the event it delivered, how many times it was attempted (attempt_count), the HTTP status your endpoint answered (response_status), why the last attempt failed, and when the next attempt is due. Use Pagination for paging.

A failed attempt carries error_message, a short English phrase, and error_code, a stable code to show the failure in your own language. Show error_message for a code you do not know: more may be added.

error_codeThe last attempt failed because
http_statusyour endpoint answered outside 2xx; the status is in response_status
connection_failedno connection could be made to your endpoint
timeoutyour endpoint did not answer within 30 seconds
tls_errorthe TLS handshake or the certificate check failed
blocked_addressyour endpoint resolves to an address that is not allowed, such as a private network
https_requiredthe endpoint URL does not use https
subscription_inactivethe subscription was switched off
delivery_failedany other failure

Retry a Failed Delivery​

curl -X POST https://api.typograph.nl/v1/webhook/deliveries/{id}/retry \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

This forces a new attempt immediately, bypassing the normal exponential-backoff schedule, and answers the delivery with status retrying. A delivery's status is pending, delivered, failed or retrying. Any delivery that has not arrived (pending, failed or retrying) can be retried; one that is already delivered is refused with 400 (invalid_request, "Cannot retry a delivered webhook"). Each retry is a new request to your endpoint, so one delivery is retried at most once a minute: another retry of it within that minute is refused with 429 (rate_limit_exceeded), and the Retry-After header says how many seconds to wait.

Complete Handler Example (Node.js)​

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.TYPOGRAPH_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
const processed = new Set();

function verify(webhookId, timestamp, rawBody, sigHeader, secret) {
if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret)
.update(`${webhookId}.${timestamp}.${rawBody}`)
.digest('base64'));
// Two space-separated "v1,<sig>" values for 24 hours after a secret rotation.
return sigHeader.split(' ').some((entry) => {
const received = Buffer.from(entry.startsWith('v1,') ? entry.slice(3) : entry);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}

app.post('/webhooks/typograph',
express.raw({ type: 'application/json' }),
async (req, res) => {
const webhookId = req.headers['webhook-id'];
const ok = verify(
webhookId,
req.headers['webhook-timestamp'],
req.body.toString(),
req.headers['webhook-signature'],
WEBHOOK_SECRET,
);
if (!ok) return res.status(401).json({ error: 'Invalid signature' });

// Respond immediately, process async (must respond within 30 s)
res.status(200).json({ received: true });

if (processed.has(webhookId)) return;
processed.add(webhookId);

const payload = JSON.parse(req.body);
if (payload.status === 'completed') {
// payload.job_id available; fetch the generated file
const fileUrl = `https://api.typograph.nl/v1/publisher/jobs/${payload.job_id}/files?file_type=output`;
await downloadAndProcess(fileUrl);
} else if (payload.status === 'failed') {
console.error(`Job ${payload.job_id} failed: ${payload.error}`);
}
},
);

app.listen(3000);

Best Practices​

  • Respond within 30 seconds. ACK with 200 immediately, process asynchronously.
  • Verify every request. Signature + timestamp window (5 min). Reject unverified payloads.
  • Deduplicate on webhook-id. Retries may re-deliver the same event.
  • Keep handlers idempotent. Same payload processed twice must produce the same result.
  • Monitor failure_count. Subscriptions auto-disable after 10 consecutive failures.
  • Rotate your secret periodically with POST /subscriptions/{id}/rotate-secret, and always keep it out of source control. Verify against both signatures during the 24-hour overlap (see Rotate Secret).

Direct Upload Alternative​

Instead of (or in addition to) webhooks, publisher and converter jobs can upload the generated file directly to a URL you control by setting output.upload.url on the job request:

{
"output": {
"upload": {
"url": "https://your-server.com/uploads/",
"headers": { "Authorization": "Bearer your-upload-token" }
},
"config": { "type": "pdf" }
}
}

Typograph PUTs the generated bytes to that URL once the job completes, which is convenient for delivery straight to cloud storage (S3, GCS, Azure Blob). You can still subscribe to publication.completed / conversion.completed for the job-status signal. See Publisher and Converter for the full job schema.