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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string (≤ 255) | yes | Human-readable subscription name |
client_id | UUID v7 | yes | OAuth client the subscription belongs to |
endpoint.url | URL (≤ 2000) | yes | HTTPS endpoint that will receive deliveries |
endpoint.headers | object | no | Up to 5 custom headers sent with every delivery |
event_types | array, ≥ 1 entry | yes | Event 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 type | Trigger |
|---|---|
publication.completed | A publisher (PDF/image generation) job finished successfully |
publication.failed | A publisher job failed |
conversion.completed | A converter (InDesign / font / image conversion) job finished successfully |
conversion.failed | A converter job failed |
Webhook Delivery
When an event fires, the webhook service POSTs the event payload to endpoint.url.
HTTP Request
| Property | Value |
|---|---|
| Method | POST |
| Content-Type | application/json |
| Timeout | 30 seconds |
| TLS | Required (HTTPS endpoints only) |
Headers
Every delivery includes Standard Webhooks signature headers:
| Header | Description | Example |
|---|---|---|
webhook-id | Unique delivery identifier (UUID) | 019b28fb-a11e-7641-a28f-e978f892ec06 |
webhook-timestamp | Unix epoch seconds at send time | 1704672000 |
webhook-signature | HMAC-SHA256 signature, format v1,{base64} | v1,K3Z8mHdf... |
Content-Type | application/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:
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1 (initial) | immediate | 0 |
| 2 | 1 min | 1 min |
| 3 | 5 min | 6 min |
| 4 | 15 min | 21 min |
| 5 | 1 hr | 1 hr 21 min |
| 6 | 2 hr | 3 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.
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
- Extract
webhook-id,webhook-timestamp, andwebhook-signaturefrom the request headers. - Reject if
|now - webhook-timestamp| > 300seconds (prevents replay). - Recompute the signature using your stored
secret. - 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
200immediately, 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.