Outbound event webhooks
PostgreSQL and SQL Server installations can register tenant subscriptions at
/webhooks. All management and delivery-log operations require admin scope.
File storage returns 503 service_unavailable. The editor’s Webhooks dialog
offers subscriptions, one-time secret reveal, test delivery, rotation, delivery
payloads and redelivery.
Create a subscription with POST /webhooks:
{"url":"https://integrator.example/events","events":["label.*","render.failed"],"templateKeys":["invoice"]}
events accepts catalogue names, a matching prefix wildcard, or *. Multiple
matching patterns produce one delivery per subscription. An optional nonempty
templateKeys list matches the event’s templateKey; events without a template
key cannot match that filter. Omit it or send an empty list for all templates.
The server generates a whsec_ secret, or accepts a supplied secret of at least
16 characters. Save the returned secret: subsequent reads show ***.
Envelope and headers
The exact UTF-8 body is stored and signed without reserialising it:
{"id":"evt_example","type":"label.promoted","apiVersion":"1","createdAt":"2026-10-12T00:00:00.000Z","correlationId":"inv-1234","data":{"templateKey":"invoice","label":"live","version":"1.2.0","previousVersion":"1.1.0","actor":"alice"}}
Headers are X-PulpEngine-Event, X-PulpEngine-Event-Id,
X-PulpEngine-Delivery-Id, X-PulpEngine-Timestamp (Unix seconds) and
X-PulpEngine-Signature (sha256=<hex>). The MAC is HMAC-SHA256 over
timestamp + "." + rawBody. The envelope and public delivery DTO omit tenant IDs.
The authenticated subscription determines the tenant. Request IDs remain
server-owned; a valid caller correlation ID travels in the envelope.
Catalogue (GET /webhooks/event-types) and representative data values:
| Type | Example data |
|---|---|
template.created |
{"templateKey":"invoice","version":"1.0.0","actor":"alice"} |
template.updated |
{"templateKey":"invoice","version":"1.0.1","previousVersion":"1.0.0","actor":"alice","changeNote":"Updated footer"} |
template.deleted |
{"templateKey":"invoice","actor":"alice"} |
template.version_restored |
{"templateKey":"invoice","restoredFrom":"1.0.0","version":"1.0.2","actor":"alice"} |
label.promoted |
{"templateKey":"invoice","label":"live","version":"1.0.2","previousVersion":"1.0.1","actor":"alice","testRunId":"test-run"} |
label.deleted |
{"templateKey":"invoice","label":"staging","version":"1.0.1","actor":"alice"} |
asset.uploaded |
{"id":"asset-id","filename":"logo.png","mimeType":"image/png","actor":"alice"} |
asset.deleted |
{"id":"asset-id","filename":"logo.png","actor":"alice"} |
render.failed |
{"templateKey":"invoice","format":"pdf","mode":"single","errorCode":"template_expression_error","message":"Invalid expression","requestId":"server-uuid","metadata":{"invoiceNo":"1234"}} |
job.completed |
{"jobId":"job-uuid","kind":"batch","successCount":9,"failureCount":1} |
job.failed |
{"jobId":"job-uuid","kind":"single","error":"render_failed"} |
schedule.execution.completed |
{"scheduleId":"schedule-id","executionId":"execution-id","status":"completed"} |
schedule.execution.failed |
{"scheduleId":"schedule-id","executionId":"execution-id","status":"failed","error":"Render failed"} |
schedule.execution.skipped |
{"scheduleId":"schedule-id","reason":"catchup_window_exceeded","fireTime":"2026-10-12T00:00:00.000Z"} |
delivery.exhausted |
{"jobId":"job-uuid","kind":"batch-callback","attempts":3} (scheduled deliveries instead identify the schedule/execution and target) |
webhook.ping |
{"message":"Pulp Engine webhook test"} |
Actors, previous pointers and correlation IDs can be null. Optional fields depend
on the event. render.failed.message is capped at 500 characters and omitted
when RENDER_LOG_ERROR_MESSAGES=false. Render input data and document bytes
are never included. Existing plugin events and completion callbacks continue
to work; restoring a version produces one domain restore event.
Verify before parsing
Node (the TypeScript SDK supports both its legacy positional helper and this header-based form):
import { verifyWebhookSignature } from '@pulp-engine/sdk'
const valid = verifyWebhookSignature({ body: rawBody, headers, secret })
if (!valid) throw new Error('Invalid signature')
Python:
from pulp_engine.webhooks import verify_webhook_signature
valid = verify_webhook_signature(body=raw_body, headers=headers, secret=secret)
if not valid:
raise ValueError("Invalid signature")
Preserve received bytes. Both helpers accept case-insensitive headers, reject timestamps more than 300 seconds from the receiver clock by default, and accept any valid comma-separated signature during rotation. Maintain synchronised clocks.
Shared golden vector (tests override the clock): secret
golden-secret-key-32-characters!!, timestamp 1791763200, body exactly:
{"id":"evt_golden","type":"webhook.ping","apiVersion":"1","createdAt":"2026-10-12T00:00:00.000Z","correlationId":null,"data":{"message":"hello"}}
Expected hex MAC:
87bee10971bb35db3428507d599456ef5823f75c5a31b6502b9a14cda038ef65.
Delivery, recovery and rotation
Delivery is at least once with no ordering guarantee. Deduplicate using the event
ID, including across manual redelivery (which keeps the event ID and exact body,
but creates a new delivery ID with redeliveryOf). Make processing idempotent,
then acknowledge quickly with 2xx. A process crash after receiver acceptance can
cause a duplicate when its stalled claim is recovered.
Eight attempts use durable delays: immediate, then 1 minute, 5 minutes,
30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after the previous attempt.
5xx, 408, 429 and network errors retry. Other 4xx and SSRF blocks are terminal.
HTTP 410 also disables the subscription with reason gone; re-enable after
fixing the endpoint. Targets are validated at creation/update and again on
delivery, with DNS pinning and private-address protection.
POST /webhooks/:id/ping sends one synchronous attempt even when disabled and records it without automatic retry. POST /webhooks/:id/rotate-secret reveals the new secret once; its body
accepts previousSecretTtlSeconds (default 86400, range 0–604800). During that
window deliveries have both new and previous signatures. Update your receiver,
then expire the previous secret. GET /webhooks/:id/deliveries paginates attempts;
get an individual delivery for its exact payload. Redelivery of a disabled
subscription returns 409.
Lifecycle events await enqueue for up to two seconds after the mutation commits. An enqueue outage does not roll back the mutation. Render-failure enqueue is fire-and-forget. Subscription and pending-delivery caps bound storage; dropped events and enqueue failures increment metrics. Retention deletes terminal deliveries after 30 days by default; pending work is retained. Deleting a subscription also deletes its delivery history. Secrets are stored in the database to sign requests: restrict database access and encrypt backups.