Async rendering + webhooks¶
For large or slow renders you don't want to block on, submit the job and either poll for status or get a signed webhook when it's done. Async results are always delivered as a signed URL (never streamed bytes).
Submit a job¶
POST /pdf/v1/async — same html / data / options / security as the sync HTML endpoint, plus an
optional webhook_url:
curl -X POST https://api.kikidoc.dev/pdf/v1/async \
-H "X-API-Key: $KIKIDOC_API_KEY" -H "Content-Type: application/json" \
-d '{
"html": "<h1>Big report</h1>",
"webhook_url": "https://your-app.example.com/kikidoc-callback"
}'
Poll for status¶
GET /pdf/v1/jobs/{job_id}:
{
"job_id": "5f2c…",
"status": "done",
"url": "https://…/renders/5f2c.pdf?…",
"expires_at": "2026-06-24T12:00:00Z"
}
status is one of queued · running · done · failed (with error set on failure). A job that
isn't yours returns 404 (no existence leak).
Webhook callback¶
If you passed webhook_url, KikiDoc POSTs this JSON on completion:
Verify the signature¶
The body is signed with HMAC-SHA256 over the raw request bytes, in the header:
Recompute and compare in constant time (key = your webhook signing secret):
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Dedupe on job_id (each job completes once) and reject deliveries whose expires_at has passed.