Skip to content

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"
  }'
{ "job_id": "5f2c…", "status": "queued" }

Poll for status

GET /pdf/v1/jobs/{job_id}:

curl https://api.kikidoc.dev/pdf/v1/jobs/5f2c… -H "X-API-Key: $KIKIDOC_API_KEY"
{
  "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:

{ "job_id": "5f2c…", "status": "done", "url": "https://…", "expires_at": "2026-06-24T12:00:00Z" }

Verify the signature

The body is signed with HMAC-SHA256 over the raw request bytes, in the header:

X-KikiDoc-Signature: sha256=<hex digest>

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.