Webhooks let PixlDocs notify your server when an async render finishes, instead of polling GET /v1/renders/:id. Register one or more endpoints; every matching render then POSTs a signed event to your URL.
POST /v1/renders, or POST /v1/render with async: true or save_to_renders: true. A plain synchronous call — POST /v1/render/html, /v1/render/url, or /v1/render without either flag — hands you the document in the response body, so there is nothing to notify you about later and no event is sent. If you registered an endpoint and see nothing arrive, this is almost always why: add "save_to_renders": true to the call.Configuring an endpoint
Two ways — the dashboard, or the API with a live key:
- Dashboard: Account → Webhooks → add an HTTPS URL, copy the signing secret (
whsec_…). - API:
POST /v1/webhook-endpoints(below). The response includes the signing secret once.
Subscribe to render.succeeded and/or render.failed (the default is both).
The URL has to be reachable from the internet
Deliveries leave our servers, not your browser, so the endpoint is checked when you register it and again before every single attempt. A URL that resolves to a loopback, private, link-local or cloud-metadata address is refused with url_blocked, and a delivery refused this way is marked failed and never retried — retrying could not change the answer. While you are developing locally, put a tunnel such as ngrok or Cloudflare Tunnel in front of your server and register the public URL it gives you.
Webhooks are on every plan, including Free. Renders cost credits; being told about them does not.
Manage endpoints via the API
# Create (live key required). signing_secret is returned ONCE.
curl -X POST https://api.pixldocs.com/v1/webhook-endpoints \
-H "Authorization: Bearer pxd_..." -H "Content-Type: application/json" \
-d '{ "url": "https://your.app/webhooks/pixldocs",
"events": ["render.succeeded","render.failed"],
"description": "prod" }'
# List / get / update / delete
curl https://api.pixldocs.com/v1/webhook-endpoints -H "Authorization: Bearer pxd_..."
curl https://api.pixldocs.com/v1/webhook-endpoints/{id} -H "Authorization: Bearer pxd_..."
curl -X PATCH .../v1/webhook-endpoints/{id} -H "Authorization: Bearer pxd_..." -d '{ "enabled": false }'
curl -X DELETE .../v1/webhook-endpoints/{id} -H "Authorization: Bearer pxd_..."
# Send a sample event to verify wiring + your signature check
curl -X POST .../v1/webhook-endpoints/{id}/test -H "Authorization: Bearer pxd_..."When a delivery fails
Every endpoint keeps an attempt log. Read it to see what your server actually returned — the status code, the first 500 characters of the response body, and, for a connection failure, the network error we hit.
# Recent attempts (newest first). limit defaults to 25, max 100.
curl "https://api.pixldocs.com/v1/webhook-endpoints/{id}/deliveries?limit=25" \
-H "Authorization: Bearer pxd_..."
# Only the ones that failed
curl ".../v1/webhook-endpoints/{id}/deliveries?status=failed" -H "Authorization: Bearer pxd_..."
# Re-send one delivery after you've fixed your handler
curl -X POST .../v1/webhook-endpoints/{id}/deliveries/{delivery_id}/retry \
-H "Authorization: Bearer pxd_..."{
"data": [
{
"id": "862d8f38-f330-4cfa-a24f-1152c3c1a4ed",
"event_type": "render.succeeded",
"status": "failed",
"attempts": 3,
"response_status": 500,
"response_body": "Internal Server Error",
"next_attempt_at": null,
"delivered_at": null,
"created_at": "2026-07-31T15:08:41.000Z"
}
]
}status is pending, delivered or failed. A delivery stays pending while attempts remain and only becomes failed once the last one is spent — so during a retry window it will not show up under ?status=failed; read attempts and response_body instead. A connection failure has no response_status and records the transport error in the body, e.g. network: fetch failed: ENOTFOUND. The endpoint itself also carries last_status and last_delivery_at for a quick health check. A retry re-sends the same event id, so a consumer that keys on it stays idempotent.
Delivery
PixlDocs POSTs a JSON body to your endpoint with these headers:
Content-Type: application/json
X-Pixldocs-Event: render.succeeded
X-Pixldocs-Delivery: 34322214-999e-4121-a138-be90341ed268
X-Pixldocs-Signature: t=1710000000,v1=5b1d...Payload
{
"id": "34322214-999e-4121-a138-be90341ed268",
"type": "render.succeeded",
"created": 1710000000,
"data": {
"id": "1a1c8507-52a2-45d8-b0e1-d8eb7dafff79",
"render_id": "38eb0b6d-2be3-4954-81d2-517c92bdf74a",
"status": "succeeded",
"template_id": "da100001-0000-4000-8000-000000000001",
"url": "https://.../render.pdf",
"format": "pdf",
"page_count": 3,
"width": null,
"height": null,
"file_size": 204307,
"duration_ms": 6157
}
}width and height are pixel dimensions and are null for PDF output; use page_count there. data.id is the job id. data.render_id is the render id — the one GET /v1/renders/:id takes and the one the Renders archive shows, so that is the value to store against your own record. It is null when a job produced no stored render row. For render.failed, data.status is "failed" and data.error carries the reason. The signed url is valid for 7 days.
Test events
The Test button on Account → Webhooks sends the same event, so you can build and deploy your handler before you run a single render. It is a real render.succeeded body with every field above, plus "test": true in data. If your workspace has already stored a render, the test copies that render's real ids and a working signed url — so GET /v1/renders/:id answers from inside your handler. If it has not, the ids are sample values and the url is null.
Ignore data.test if you want the test to exercise your real code path; branch on it if a test must not touch your database.
Verifying signatures
Compute HMAC-SHA256 over the raw request body prefixed with the timestamp — `${t}.${rawBody}` — using your signing secret, and compare to v1. Reject deliveries whose t is older than 5 minutes.
import crypto from 'node:crypto';
export function verify(sigHeader: string, rawBody: string, secret: string) {
const parts = Object.fromEntries(sigHeader.split(',').map(kv => kv.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; // replay guard
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex');
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Retries
Non-2xx responses (and timeouts > 10s) are retried with exponential backoff — up to 5 attempts, roughly 5s, 10s, 20s and 40s after the first one, so a delivery is given up on about 75 seconds after the first try rather than over hours. next_attempt_at on the delivery row carries the scheduled time for the next one and is null once the budget is spent. Return a 2xx as soon as you've accepted the event; do slow work asynchronously.
id (also sent as the X-Pixldocs-Delivery header) as an idempotency key on your side.