Jobs and webhooks
A job is a render that doesn’t wait on your request: you submit it, the server’s workers render it, and you find out when it’s done by asking, or by a webhook that tells your server. Use jobs for anything slow, or when your system shouldn’t hold a request open.
Submit a job
Section titled “Submit a job”A job takes what a render takes, plus the template:
curl -H "Authorization: Bearer $PAPRIKA_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: badge-grace-2026" -d '{"template": "tpl_9fK2mQ7rLx4VbN1s", "values": {"name": "Grace Hopper", "role": "Keynote speaker"}}' "$PAPRIKA_URL/v1/jobs"{ "id": "job_K7mN2pQ9rS4tU6vW8xY1", "status": "queued", "template": "tpl_9fK2mQ7rLx4VbN1s", "version": 1, "format": "pdf", "attempts": 0}The values are checked as the job is submitted, so a problem with them is refused at once (422) and only a job that can run is queued. The Idempotency-Key header makes a retried submit safe: for 24 hours the same key with the same request gives back the same job.
Ask how it went
Section titled “Ask how it went”curl -H "Authorization: Bearer $PAPRIKA_KEY" "$PAPRIKA_URL/v1/jobs/job_K7mN2pQ9rS4tU6vW8xY1"{ "id": "job_K7mN2pQ9rS4tU6vW8xY1", "status": "done", "template": "tpl_9fK2mQ7rLx4VbN1s", "version": 1, "format": "pdf", "attempts": 1, "output": { "id": "out_Q2nW8vX4zR6tY1uI3oP5", "download": "/v1/outputs/out_Q2nW8vX4zR6tY1uI3oP5" }}A job is queued, running, then done, failed or cancelled. A done job has its output, as a kept render has, with a signedDownload link that works without a key for an hour. A failed job has its error: what a render would have been refused with. Download the file:
curl -H "Authorization: Bearer $PAPRIKA_KEY" -o grace.pdf "$PAPRIKA_URL/v1/outputs/out_Q2nW8vX4zR6tY1uI3oP5"A queued job can be cancelled (POST /v1/jobs/<id>/cancel); GET /v1/jobs lists your jobs, newest first.
Be told by a webhook
Section titled “Be told by a webhook”Register an endpoint of yours, an https:// address on the internet:
curl -H "Authorization: Bearer $PAPRIKA_KEY" -H "Content-Type: application/json" -d '{"url": "https://hooks.example.com/paprika", "events": ["job.done", "job.failed"]}' "$PAPRIKA_URL/v1/webhooks"{ "id": "whk_M3n4O5p6Q7r8S9t0", "url": "https://hooks.example.com/paprika", "events": ["job.done", "job.failed"], "enabled": true, "secret": "whsec_example0nly"}The secret is shown only this once: keep it, your endpoint needs it. Leave out events to hear of every one: job.done, job.failed, job.cancelled, batch.done, batch.partial, batch.failed and batch.cancelled. Send a test event to check your endpoint:
curl -X POST -H "Authorization: Bearer $PAPRIKA_KEY" "$PAPRIKA_URL/v1/webhooks/whk_M3n4O5p6Q7r8S9t0/ping"When a job ends, Paprika POSTs {id, type, createdAt, job} to your endpoint, the job as asking for it shows it. Answer 2xx within 10 seconds; otherwise the delivery is tried again after 1, 5 and 30 minutes, then 2 and 6 hours. Every attempt of one event has the same Paprika-Event-Id, so you can drop repeats. GET /v1/webhooks/<id>/deliveries shows each attempt and what your endpoint answered.
Check the signature
Section titled “Check the signature”Anyone can POST to your endpoint, so check that a delivery is Paprika’s. Its Paprika-Signature header is t=<Unix seconds>,v1=<signature>, the signature being the hex HMAC-SHA256 of <t>.<body> with your secret. While a secret is being replaced (POST /v1/webhooks/<id>/secret), deliveries carry a v1 for each. Check it against the raw body, as received, and refuse old signatures, so a delivery can’t be replayed. A complete endpoint:
// PAPRIKA_WEBHOOK_SECRET=whsec_… node webhook.mjs [port]import { createHmac, timingSafeEqual } from 'node:crypto';import http from 'node:http';
const SECRET = process.env.PAPRIKA_WEBHOOK_SECRET, PORT = Number(process.argv[2] ?? 8080), TOLERANCE_SECONDS = 300, seen = new Set();
// Whether `header` signs `body` (the raw text) with `secret`, made within the tolerance.function verify(header, body, secret, now = Date.now()) { const parts = String(header ?? '') .split(',') .map((p) => p.trim().split('=')), time = Number(parts.find(([k]) => k === 't')?.[1]); if (!Number.isFinite(time) || Math.abs(now / 1000 - time) > TOLERANCE_SECONDS) return false; const wanted = Buffer.from(createHmac('sha256', secret).update(`${time}.${body}`).digest('hex')); return parts.some( ([k, v = '']) => k === 'v1' && v.length === wanted.length && timingSafeEqual(Buffer.from(v), wanted), );}
http .createServer(async (req, res) => { let body = ''; for await (const chunk of req) body += chunk; if (!verify(req.headers['paprika-signature'], body, SECRET)) { res.writeHead(400).end('bad signature'); return; } const event = JSON.parse(body); if (!seen.has(event.id)) { seen.add(event.id); // Your work here: event.job.output.signedDownload is a fresh link to the file. console.log( JSON.stringify({ event: event.id, type: event.type, id: (event.job ?? event.batch)?.id }), ); } res.writeHead(200).end('ok'); }) .listen(PORT, () => console.log(`listening on ${PORT}`));The same in Python, with the standard library only:
# PAPRIKA_WEBHOOK_SECRET=whsec_... python3 webhook.py [port]import hashlibimport hmacimport jsonimport osimport sysimport timefrom http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["PAPRIKA_WEBHOOK_SECRET"]TOLERANCE_SECONDS = 300seen = set()
def verify(header, body, secret, now=None): """Whether `header` signs `body` (bytes, as received) with `secret`, within the tolerance.""" parts = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p] stamps = [v for k, v in parts if k == "t"] try: stamp = int(stamps[0]) except (IndexError, ValueError): return False if abs((now or time.time()) - stamp) > TOLERANCE_SECONDS: return False wanted = hmac.new(secret.encode(), str(stamp).encode() + b"." + body, hashlib.sha256).hexdigest() return any(k == "v1" and hmac.compare_digest(v, wanted) for k, v in parts)
class Handler(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if not verify(self.headers.get("Paprika-Signature"), body, SECRET): self.send_response(400) self.end_headers() return event = json.loads(body) if event["id"] not in seen: seen.add(event["id"]) # Your work here: event["job"]["output"]["signedDownload"] is a fresh link to the file. subject = event.get("job") or event.get("batch") or {} print(json.dumps({"event": event["id"], "type": event["type"], "id": subject.get("id")}), flush=True) self.send_response(200) self.end_headers()
def log_message(self, *args): pass
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080print("listening on %d" % port, flush=True)HTTPServer(("0.0.0.0", port), Handler).serve_forever()