Skip to content

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.

A job takes what a render takes, plus the template:

Terminal window
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.

Terminal window
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:

Terminal window
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.

Register an endpoint of yours, an https:// address on the internet:

Terminal window
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:

Terminal window
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.

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:

webhook.mjs
// 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:

webhook.py
# PAPRIKA_WEBHOOK_SECRET=whsec_... python3 webhook.py [port]
import hashlib
import hmac
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["PAPRIKA_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300
seen = 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 8080
print("listening on %d" % port, flush=True)
HTTPServer(("0.0.0.0", port), Handler).serve_forever()