API
Webhooks
Events of generated and failed documents and published templates, signed with HMAC-SHA256 and retried.
Webhooks tell your systems what happens in a workspace without polling: a document rendered through the API, a render that failed, a template version published. Prynt sends an HTTPS POST with a JSON body to your endpoint, signs it, and retries until your endpoint answers 2xx.
How webhooks work
- Add an endpoint in Developers → Webhooks (permission
webhooks.manage): anhttps://URL on a public host, the events to receive, an optional description. Prynt refuses addresses of private networks and localhost. - Prynt shows the endpoint's signing secret (
whsec_…) once. Store it with your endpoint's configuration; Rotate secret creates a new one when needed (the old one stops signing at once). - Every delivery is logged with its attempts, the HTTP status and an excerpt of your answer. Send test event sends a
webhook.testevent to check your endpoint.
Events
| Field | Type | Description |
|---|---|---|
document.generated | event | A document was rendered through the public API (sync or async). |
document.failed | event | A render through the public API failed: errorCode and message tell why. |
template.published | event | A template version was published in the workspace. |
webhook.test | event | Sent by Send test event; always delivered, whatever the endpoint's events. |
Payload
Every event has the same envelope: id (the event id, a UUID), type, createdAt, workspaceId and the event's data.
{ "id": "0199c2f2-0b6e-7f4c-a1d2-6c8e9b0a7f31", "type": "document.generated", "createdAt": "2026-10-08T09:12:04.051Z", "workspaceId": "0199b8a1-0000-7000-8000-00000000abcd", "data": { "template": "INVOICE", "version": 7, "environment": "production", "format": "pdf", "pageCount": 2, "outputBytes": 48213, "fileName": "INVOICE_18425.pdf", "runId": "0199c2f1-5a7e-7b31-9c4d-2f0a8e6b1d23", "jobId": "0199c2f1-7c10-7d2e-8f61-1b9a4c3e5d77", "documentUrl": "https://api.prynt.it/api/v1/documents/eyJhbGciOi…", "documentExpiresAt": "2026-10-09T09:12:04.020Z", "correlationId": "4bf92f3577b34da6a3ce929d0e0e4736", "apiKey": { "id": "0199b9c0-…", "prefix": "prynt_live_k3x9a2mq", "name": "ERP production" } }}Data of each event
document.generated:template,version,environment,format,pageCount,outputBytes,fileName,runId,jobId,documentUrl(a short-lived signed link),documentExpiresAt,correlationIdand theapiKey(id, prefix, name) that asked for it.document.failed:template,version,environment,format,jobId,runId,errorCode,message,correlationIdand theapiKey.template.published:templateId,template,version,previousVersion,format,noteandpublishedBy.
Headers
POST /prynt/webhooks HTTP/1.1Content-Type: application/jsonUser-Agent: Prynt-Webhooks/1.0Prynt-Event: document.generatedPrynt-Event-Id: 0199c2f2-0b6e-7f4c-a1d2-6c8e9b0a7f31Prynt-Delivery: 0199c2f2-0c11-7a05-b3e4-9d1f2a6c8e40Prynt-Signature: t=1760000000, v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdPrynt-Event: the event type;Prynt-Event-Id: the event id (the same on every retry and redelivery);Prynt-Delivery: this delivery.Prynt-Signature:t=<unix seconds>, v1=<signature>.
Verifying signatures
v1 is the hex HMAC-SHA256 of {t}.{raw body}, keyed with the UTF-8 bytes of the whole secret (including the whsec_ prefix). To verify a delivery:
- Read the raw body exactly as received, before any JSON parsing (parsing and serializing again changes the bytes).
- Compute the HMAC of
t, a dot and the raw body, and compare it withv1in constant time. - Refuse deliveries whose
tis more than 5 minutes away from your clock: a captured request cannot be replayed later.
1using System.Security.Cryptography;2using System.Text;3 4static bool VerifyPryntSignature(string header, string rawBody, string secret, TimeSpan tolerance)5{6 // header: "t=1760000000, v1=5257a869…"7 string? t = null, v1 = null;8 foreach (var part in header.Split(',', StringSplitOptions.TrimEntries))9 {10 if (part.StartsWith("t=")) t = part[2..];11 else if (part.StartsWith("v1=")) v1 = part[3..];12 }13 if (t is null || v1 is null || !long.TryParse(t, out var seconds)) return false;14 var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(seconds);15 if (age.Duration() > tolerance) return false;16 17 using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); // the whole "whsec_…" string18 var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));19 byte[] received;20 try { received = Convert.FromHexString(v1); } catch (FormatException) { return false; }21 return CryptographicOperations.FixedTimeEquals(expected, received);22}23 24// ASP.NET Core: read the raw body before any JSON binding.25// var rawBody = await new StreamReader(Request.Body).ReadToEndAsync();26// var ok = VerifyPryntSignature(Request.Headers["Prynt-Signature"]!, rawBody, secret, TimeSpan.FromMinutes(5));Verify before trusting the content
400 to a delivery with a missing or wrong signature and do not process it. While you rotate the secret, accept both the old and the new one for a short time.Retries and redelivery
A delivery succeeds when your endpoint answers 2xx within a few seconds. Otherwise (an error status, a timeout, a connection failure) Prynt tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: up to 6 attempts. After the last one the delivery is abandoned.
- The delivery log of each webhook shows the status (pending, succeeded, failed, abandoned), the attempt, the next attempt and your answer.
- Redeliver sends an event again, as a new delivery with the same event id: useful once your endpoint is fixed.
- Disabled webhooks receive nothing; redelivery and test events answer 409 WEBHOOK_DISABLED until you enable them.
Best practices
- Answer 2xx quickly, then do the work asynchronously (a queue, a background job). Slow answers count as failures and are retried.
- Be idempotent: the same event can arrive more than once (retries, redeliveries). Store the event
idyou processed and ignore repeats. - Events can arrive out of order: rely on
createdAtand on the job or run ids, not on the arrival order. - Download
documentUrlsoon after the event: the link is short-lived. Read the job again (GET /v1/jobs/{jobId}) for a fresh link.