Register URLs in the Webhooks page to receive events. Each delivery is a JSON POST with this envelope:
{
"type": "<event-type>",
"timestamp": "<ISO-8601 timestamp, e.g. 2026-06-06T12:34:56.789Z>",
"data": { /* event-specific fields */ }
}and the following headers:
Pluma-Event— event type (matchestypein the body)Pluma-Signature: sha256=<hex>— HMAC-SHA256 of the raw bodyPluma-Delivery-Id— unique per attempt; safe to dedupe onPluma-Timestamp— delivery time, Unix secondsPluma-Signature-V2: t=<unix seconds>,v1=<hex>— HMAC-SHA256 of<timestamp>.<raw body>; verify this one and reject deliveries older than five minutes, which rules out replays
Event catalog
The complete list of event types we currently emit. Subscribe each webhook to a subset (the dashboard exposes a checkbox per event), or leave it empty to receive every event.
| Event | When | Availability |
|---|---|---|
job.completed | Async bulk job finished and the ZIP is ready to download. | Hosted + self-hosted |
job.failed | Async bulk job aborted. data.error carries a sanitised, customer-safe message — never URLs or stack traces. | Hosted + self-hosted |
render.url_expiring | Fires ~24 h before a download URL closes while the artifact still has retention left. Call data.refreshUrl for a fresh URL. | Hosted + self-hosted |
signature.requested | A sign request was created and the invite email sent. | Hosted paid tiers, self-hosted enterprise |
signature.signed | A signer submitted their signature. | Hosted paid tiers, self-hosted enterprise |
signature.declined | A signer declined. data.declineReason carries their reason, or null. | Hosted paid tiers, self-hosted enterprise |
signature.expired | A pending request passed expiresAt unsigned. | Hosted paid tiers, self-hosted enterprise |
publish.completed | A workspace publish job finished. data.imageDigest is the canonical reference for the published artifact. | Self-hosted only |
Example payload
Every event follows the same envelope — here's job.completed; the data fields per event are listed in the catalog above:
{
"type": "job.completed",
"timestamp": "2026-06-06T12:34:56.789Z",
"data": {
"jobId": "5e4a0a32-8b91-4d8f-9b56-0c2b1ef8d3a1",
"templateId": "7b1c3f10-6a08-4b97-9f1a-12e6e0c7f0a8",
"itemsTotal": 100
}
}Verify the signature
const crypto = require('crypto');
// Pluma-Signature-V2: t=<unix seconds>,v1=<hex> — signed over `<t>.<raw body>`
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // stale or replayed
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
return expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Same HMAC-SHA256 pattern in any language. The older Pluma-Signature: sha256=<hex> header (HMAC of the body alone) is still sent for existing integrations; compare it with a constant-time function only after checking the lengths match. A non-2xx delivery is retried up to four times after the first attempt, with exponential backoff (30 s, 2 min, 8 min, 32 min); after the fifth failed attempt it is marked failed — the dashboard shows every attempt.
Need help?
Open a ticket from the in-app Support page, or email [email protected].