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.

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.

List Subscriptions

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

Response: list entries are a summary view — organization_id, client_id, and secret are only returned on GET /subscriptions/{id} and at create time.

{
"data": [
{
"id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"name": "Production pipeline",
"endpoint": {
"url": "https://your-app.com/webhooks/typograph",
"headers": { "X-Tenant-Id": "tenant-123" }
},
"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 the subscription belongs to
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, and secret. Subsequent GET /subscriptions/{id} reads return the same shape (including the secret), while GET /subscriptions list entries omit those three fields.

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.

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

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}v1,K3Z8mHdf...
Content-Typeapplication/json

Plus any custom headers you configured on endpoint.headers (up to 5). The four 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 carried in the webhook-id/webhook-signature headers and correlated via the subscription's event_types list, not in the body.

publication.completed / publication.failed

The body is the publisher job snapshot plus identifiers:

{
"id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"job_id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"client_id": "019b28fb-a11e-7641-a28f-e978f892ec01",
"tag": "invoice-alpha",
"parent_id": null,
"status": "completed",
"input": { "template": { "url": "..." }, "manifest": { "url": "..." } },
"output": { "upload": { "url": "..." }, "config": { "type": "pdf" } },
"output_url": "https://your-upload-target/...",
"created_at": "<ISO 8601 timestamp>",
"completed_at": "<ISO 8601 timestamp>"
}

For publication.failed, the payload additionally includes an error field with the failure reason and status: "failed".

conversion.completed / conversion.failed

The body is the converter job snapshot plus identifiers:

{
"id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"job_id": "019b28fb-a11e-7641-a28f-e978f892ec06",
"client_id": "019b28fb-a11e-7641-a28f-e978f892ec01",
"tag": "font-conversion-001",
"status": "completed",
"input": { "type": "font", "url": "..." },
"output": { "type": "woff2", "upload": { "url": "..." } },
"created_at": "<ISO 8601 timestamp>",
"completed_at": "<ISO 8601 timestamp>"
}

For conversion.failed, the payload includes an error field and status: "failed".

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. The response status code and the first 10,000 characters of the body are stored for inspection.

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.

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 GET /subscriptions/{id} — it is not your OAuth application/client secret. Each subscription has its own.

Signature Construction

message   = "{webhook-id}.{webhook-timestamp}.{raw-body}"
signature = base64(hmac_sha256(message, secret))
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 using a timing-safe comparison.

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 received = signatureHeader.startsWith('v1,') ? signatureHeader.slice(3) : signatureHeader;
const expected = crypto
.createHmac('sha256', secret)
.update(`${webhookId}.${timestamp}.${rawBody}`)
.digest('base64');

return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

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;
}

$received = str_starts_with($signatureHeader, 'v1,') ? substr($signatureHeader, 3) : $signatureHeader;
$expected = base64_encode(hash_hmac('sha256', "{$webhookId}.{$timestamp}.{$rawBody}", $secret, true));

return hash_equals($expected, $received);
}

$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 the event type, the HTTP response status code, the stored response body (first 10,000 characters), attempt count, and next retry timestamp. Use Pagination for paging.

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.

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 received = sigHeader.startsWith('v1,') ? sigHeader.slice(3) : sigHeader;
const expected = crypto.createHmac('sha256', secret)
.update(`${webhookId}.${timestamp}.${rawBody}`)
.digest('base64');
try {
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
} catch {
return false;
}
}

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 and always keep it out of source control.

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.