Async, retention & webhooks
The two generation modes, how long async PDFs are stored, and how webhooks notify you.
Generation has two modes — sync (default, on-demand) and async (high-volume, background). Async PDFs are stored briefly in R2, so the recommended pattern is download and store on your side; webhooks are how you automate that.
Sync mode
mode: "sync" waits for the render and returns the PDF as base64 directly in
the response. It is ephemeral: Renduo does not persist the payload, the
PDF, or a generation record. There is a hard timeout (10 s), after which the
request fails with GENERATION_TIMEOUT.
Best for on-demand generation where a user is waiting on screen — invoices generated on checkout, receipts, and anything low-volume.
Async mode
mode: "async" enqueues a job and returns immediately:
{
"jobId": "3f8a2c1e-…",
"status": "queued"
}The worker renders in the background, persists the PDF to R2, and (if you have webhooks configured) notifies you. You can poll for the result:
GET /v1/generate/:jobId{
"jobId": "3f8a2c1e-…",
"status": "completed",
"pdfUrl": "https://…signed…",
"durationMs": 950,
"timings": { "…": "…" }
}pdfUrl is a short-lived signed URL to the stored PDF.
Best for high-volume or background generation — batch runs, nightly reporting, anything where nobody is waiting synchronously.
Choosing a mode
| Sync | Async | |
|---|---|---|
| Latency | blocks until done | fire-and-forget + notify |
| Returns | base64 PDF | jobId, then a signed URL |
| Persistence | ephemeral — nothing stored | PDF in R2 for the retention window |
| Best for | on-demand, user waiting | high volume, background |
Both modes cost the same and count the same toward your plan.
Retention
Async PDFs are stored in R2 only for your plan's retention window, then deleted by a scheduled sweep:
| Plan | Async retention |
|---|---|
| Free | 24 hours |
| Starter | 30 days |
| Growth | 90 days |
| Enterprise | custom / BYOS |
The generation record stays (marked expired); only the binary is purged. Because the window is short, the recommended practice is:
Download the PDF and store it on your side. Your template version is immutable, so you can always regenerate the exact same document from the same payload later — that is your real backup.
Webhooks
Webhooks notify you when an async generation finishes — the piece that lets you automate download and store instead of polling.
Events
| Event | Fires when |
|---|---|
generation.completed | An async job finished successfully. |
generation.failed | An async job failed or timed out. |
Sync generations never fire webhooks — the PDF is in the response, there is nothing to notify.
Payload
{
"event": "generation.completed",
"generationId": "3f8a2c1e-…",
"templateId": "…",
"templateVersionId": "…",
"environmentId": "…",
"status": "completed",
"createdAt": "2026-08-03T12:00:00Z",
"pdfUrl": "https://…signed…",
"pdfUrlExpiresAt": "2026-08-04T12:00:00Z",
"durationMs": 950,
"timings": { "…": "…" }
}The payload never contains the document's data — only identifiers and metadata.
Signature verification
Deliveries are signed with HMAC-SHA256 using the endpoint's secret, sent in
the Renduo-Signature header:
Renduo-Signature: t=<unix_ms>,v1=<hex_hmac_sha256>The signed value is <timestamp>.<raw body> — the timestamp is inside the
signature, so replaying an old delivery fails the freshness check (5-minute
tolerance). Always verify the signature before trusting the body:
import { createHmac, timingSafeEqual } from 'crypto'
const SECRET = process.env.RENDUO_WEBHOOK_SECRET
function verify(secret: string, body: string, header: string, now = Date.now()) {
const [tPart, v1Part] = header.split(',')
const timestamp = Number(tPart.split('=')[1])
const signature = v1Part.split('=')[1]
if (Math.abs(now - timestamp) > 5 * 60 * 1000) return false // replay
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}Delivery & retries
Delivery is https-only, does not follow redirects, and is SSRF-guarded
(Renduo will not reach private/link-local addresses — it will not probe your
internal network). Failed deliveries retry with exponential backoff (5 attempts
over ~30 minutes). An endpoint that exhausts retries on 3 separate events is
automatically disabled and flagged in the dashboard.
Availability
Webhooks and the priority queue are Growth features (and Enterprise). On lower plans the dashboard shows them as existing-but-locked.
See also
- Async + webhooks end-to-end guide — a full worked example.
- Plans & limits — retention and feature gating by plan.
- API reference — Generations