Jobs and webhooks
Background jobs, signed completion events, and how to verify them.
Run long operations, such as pipelines on large files or batches of up to 50 PDFs, as background jobs. Check on a job, or have superPDF send a signed event to an address you choose when it finishes.
Job lifecycle
POST /v1/jobs takes a tool name and its input, answers 202 at once, and runs jobs one at a time in the order received.
| status | Meaning |
|---|---|
queued | Waiting for the jobs ahead of it. |
running | Running now. progress shows how far it is, for tools that report it (merge, split, pipelines and batches). |
succeeded | Finished. result is the same result the tool’s own endpoint returns. |
failed | Finished with an error. error is a problem object, as in Errors. |
Jobs live in the API’s memory: finished jobs are kept for an hour (at most 500), and all jobs are gone when the API stops. A job’s input, including any password, is discarded once it has run.
Check on a job
Poll GET /v1/jobs/<id> until the status is succeeded or failed:
JOB=$(curl -s http://127.0.0.1:7341/v1/jobs \
-H "Authorization: Bearer $SUPERPDF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tool": "pdf_run_batch",
"input": {
"inputs": ["report.pdf", "appendix.pdf"],
"steps": [{ "action": "watermark", "options": { "text": "DRAFT" } }],
"output_folder": "drafts"
}
}' | jq -r .id)
# Check twice a second until the job has finished
while true; do
STATUS=$(curl -s http://127.0.0.1:7341/v1/jobs/$JOB \
-H "Authorization: Bearer $SUPERPDF_API_TOKEN" | tee job.json | jq -r .status)
[ "$STATUS" = queued ] || [ "$STATUS" = running ] || break
sleep 0.5
done
jq '.result // .error' job.jsonimport { request, type BatchResult, type Job } from './superpdf.ts';
let job = await request<Job<BatchResult>>('POST', '/v1/jobs', {
tool: 'pdf_run_batch',
input: {
inputs: ['report.pdf', 'appendix.pdf'],
steps: [{ action: 'watermark', options: { text: 'DRAFT' } }],
output_folder: 'drafts',
},
});
// Check twice a second until the job has finished
while (job.status === 'queued' || job.status === 'running') {
await new Promise(resolve => setTimeout(resolve, 500));
job = await request<Job<BatchResult>>('GET', `/v1/jobs/${job.id}`);
}
if (!job.result) throw new Error(job.error?.detail);
for (const file of job.result.outputs) console.log(`${file.input} -> ${file.output}`);
import time
from superpdf import BatchResult, Job, request
job: Job = request("POST", "/v1/jobs", {
"tool": "pdf_run_batch",
"input": {
"inputs": ["report.pdf", "appendix.pdf"],
"steps": [{"action": "watermark", "options": {"text": "DRAFT"}}],
"output_folder": "drafts",
},
})
# Check twice a second until the job has finished
while job["status"] in ("queued", "running"):
time.sleep(0.5)
job = request("GET", f"/v1/jobs/{job['id']}")
if job["status"] == "failed":
raise SystemExit(job["error"]["detail"])
result: BatchResult = job["result"]
for file in result["outputs"]:
print(f"{file['input']} -> {file['output']}")
package main
import (
"fmt"
"log"
"time"
)
func main() {
var job Job[BatchResult]
err := Request("POST", "/v1/jobs", map[string]any{
"tool": "pdf_run_batch",
"input": map[string]any{
"inputs": []string{"report.pdf", "appendix.pdf"},
"steps": []map[string]any{
{"action": "watermark", "options": map[string]string{"text": "DRAFT"}},
},
"output_folder": "drafts",
},
}, &job)
if err != nil {
log.Fatal(err)
}
// Check twice a second until the job has finished
for job.Status == "queued" || job.Status == "running" {
time.Sleep(500 * time.Millisecond)
if err := Request("GET", "/v1/jobs/"+job.ID, nil, &job); err != nil {
log.Fatal(err)
}
}
if job.Result == nil {
log.Fatal(job.Error)
}
for _, file := range job.Result.Outputs {
fmt.Printf("%s -> %s\n", file.Input, file.Output)
}
}
{
"id": "job_c5ece8a8-5f0f-4121-97ec-9ca994a94c10",
"tool": "pdf_run_batch",
"status": "succeeded",
"progress": {
"progress": 2,
"total": 2,
"message": "2 of 2 files"
},
"created_at": "2026-10-11T01:55:36.410Z",
"started_at": "2026-10-11T01:55:36.410Z",
"finished_at": "2026-10-11T01:55:36.414Z",
"result": {
"outputs": [
{
"input": "/Users/<you>/Documents/superPDF/report.pdf",
"output": "/Users/<you>/Documents/superPDF/drafts/report-processed.pdf",
"pages": 4,
"bytes": 2916
},
{
"input": "/Users/<you>/Documents/superPDF/appendix.pdf",
"output": "/Users/<you>/Documents/superPDF/drafts/appendix-processed.pdf",
"pages": 1,
"bytes": 1405
}
],
"failed": []
}
}Get notified
Instead of polling, give the job a callback_url. First start the API with a webhook secret that your receiver also knows:
export SUPERPDF_API_TOKEN="$(openssl rand -hex 32)"
# The secret superPDF signs events with; your receiver checks it
export SUPERPDF_WEBHOOK_SECRET="whsec_$(openssl rand -base64 32)"
superpdf serve --allow-dir ~/Documents/superPDFWithout SUPERPDF_WEBHOOK_SECRET, the API makes a new secret on every start and prints it. Then start the job:
curl http://127.0.0.1:7341/v1/jobs \
-H "Authorization: Bearer $SUPERPDF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tool": "pdf_run_batch",
"input": {
"inputs": ["report.pdf", "appendix.pdf"],
"steps": [{ "action": "watermark", "options": { "text": "DRAFT" } }],
"output_folder": "drafts"
},
"callback_url": "http://127.0.0.1:8080/hooks/superpdf"
}'import { request, type BatchResult, type Job } from './superpdf.ts';
const job = await request<Job<BatchResult>>('POST', '/v1/jobs', {
tool: 'pdf_run_batch',
input: {
inputs: ['report.pdf', 'appendix.pdf'],
steps: [{ action: 'watermark', options: { text: 'DRAFT' } }],
output_folder: 'drafts',
},
callback_url: 'http://127.0.0.1:8080/hooks/superpdf',
});
console.log(`${job.id} is ${job.status}`);
from superpdf import Job, request
job: Job = request("POST", "/v1/jobs", {
"tool": "pdf_run_batch",
"input": {
"inputs": ["report.pdf", "appendix.pdf"],
"steps": [{"action": "watermark", "options": {"text": "DRAFT"}}],
"output_folder": "drafts",
},
"callback_url": "http://127.0.0.1:8080/hooks/superpdf",
})
print(f"{job['id']} is {job['status']}")
package main
import (
"fmt"
"log"
)
func main() {
var job Job[BatchResult]
err := Request("POST", "/v1/jobs", map[string]any{
"tool": "pdf_run_batch",
"input": map[string]any{
"inputs": []string{"report.pdf", "appendix.pdf"},
"steps": []map[string]any{
{"action": "watermark", "options": map[string]string{"text": "DRAFT"}},
},
"output_folder": "drafts",
},
"callback_url": "http://127.0.0.1:8080/hooks/superpdf",
}, &job)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s is %s\n", job.ID, job.Status)
}
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /v1/jobs/job_f5a4c3fc-c43f-4655-8a35-f8821bf7cd4f
{
"id": "job_f5a4c3fc-c43f-4655-8a35-f8821bf7cd4f",
"tool": "pdf_run_batch",
"status": "running",
"progress": null,
"created_at": "2026-10-11T01:39:03.888Z",
"started_at": "2026-10-11T01:39:03.889Z",
"finished_at": null,
"callback": {
"url": "http://127.0.0.1:8080/hooks/superpdf",
"status": "pending",
"attempts": 0,
"last_status": null
}
}callback follows the delivery: pending until the job finishes, then delivering, and finally delivered or failed, with the number of attempts and the last HTTP status your receiver answered.
Events
When the job finishes, superPDF sends one POST to callback_url:
POST /hooks/superpdf HTTP/1.1
content-type: application/json
user-agent: superPDF/0.1.0
webhook-id: msg_5dfdfc12-4548-4899-bfb9-9c37addfc594
webhook-timestamp: 1791682743
webhook-signature: v1,wp2AKnccq3DgITSFsK4PkiEjt8f2lEjJF1uNfEBYX9c=
{
"type": "job.succeeded",
"timestamp": "2026-10-11T01:39:03.906Z",
"data": {
"job_id": "job_f5a4c3fc-c43f-4655-8a35-f8821bf7cd4f",
"tool": "pdf_run_batch",
"status": "succeeded",
"created_at": "2026-10-11T01:39:03.888Z",
"finished_at": "2026-10-11T01:39:03.906Z",
"outputs": [
"/Users/<you>/Documents/superPDF/drafts/report-processed.pdf",
"/Users/<you>/Documents/superPDF/drafts/appendix-processed.pdf"
]
}
}type is job.succeeded or job.failed; a failed job’s event has data.error with code and detail. Events list the files a job wrote, never their contents or extracted text. To get the full result, fetch the job.
Verify events
Events are signed in the Standard Webhooks format. To check one:
- Take the key: the secret without its
whsec_prefix, decoded from base64. - Compute HMAC-SHA256 of
webhook-id,webhook-timestampand the request body, joined with dots, and encode it as base64. - Put
v1,in front and compare it, in constant time, with each space-separated value inwebhook-signature. - Refuse timestamps more than five minutes away from now, and skip a
webhook-idyou have already handled.
Sign the body exactly as it arrived: parsing the JSON and serialising it again changes the bytes, and the signature won’t match. These receivers do all four steps, answer at once, and print each event:
import { createServer, type IncomingHttpHeaders } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
interface JobEvent {
type: 'job.succeeded' | 'job.failed';
timestamp: string;
data: {
job_id: string;
tool: string;
status: 'succeeded' | 'failed';
outputs: string[];
error?: { code: string; detail: string };
};
}
const secret = process.env.SUPERPDF_WEBHOOK_SECRET ?? '';
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const handled = new Set<string>();
/** Standard Webhooks: "v1," + base64 HMAC-SHA256 of "id.timestamp.body". */
function verify(headers: IncomingHttpHeaders, body: Buffer): boolean {
const id = String(headers['webhook-id'] ?? '');
const timestamp = String(headers['webhook-timestamp'] ?? '');
if (!id || !/^\d+$/.test(timestamp)) return false;
// Refuse events more than five minutes old (or ahead)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const mac = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body);
const expected = Buffer.from(`v1,${mac.digest('base64')}`);
const signatures = String(headers['webhook-signature'] ?? '').split(' ');
return signatures.some(signature => {
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
const server = createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/hooks/superpdf') {
res.writeHead(404).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks);
if (!verify(req.headers, body)) {
res.writeHead(401).end();
return;
}
res.writeHead(204).end(); // answer at once: superPDF waits 10 seconds at most
const id = String(req.headers['webhook-id']);
if (handled.has(id)) return; // a retry of an event already handled
handled.add(id);
const event: JobEvent = JSON.parse(body.toString('utf8'));
const files = event.data.outputs.length;
console.log(`${event.type} ${event.data.job_id}: ${files} file(s)`);
});
server.listen(8080, '127.0.0.1', () => {
console.log('Listening on http://127.0.0.1:8080/hooks/superpdf');
});
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
secret = os.environ["SUPERPDF_WEBHOOK_SECRET"]
key = base64.b64decode(secret.removeprefix("whsec_"))
handled: set[str] = set()
def verify(headers, body: bytes) -> bool:
"""Standard Webhooks: "v1," + base64 HMAC-SHA256 of "id.timestamp.body"."""
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
if not msg_id or not timestamp.isascii() or not timestamp.isdigit():
return False
# Refuse events more than five minutes old (or ahead)
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{msg_id}.{timestamp}.".encode() + body
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = b"v1," + base64.b64encode(digest)
signatures = headers.get("webhook-signature", "").split(" ")
return any(hmac.compare_digest(s.encode(), expected) for s in signatures)
class Receiver(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != "/hooks/superpdf":
self.send_error(404)
return
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify(self.headers, body):
self.send_response(401)
self.end_headers()
return
self.send_response(204) # answer at once: superPDF waits 10 seconds at most
self.end_headers()
msg_id = self.headers["webhook-id"]
if msg_id in handled: # a retry of an event already handled
return
handled.add(msg_id)
event = json.loads(body)
files = len(event["data"]["outputs"])
print(f"{event['type']} {event['data']['job_id']}: {files} file(s)")
server = HTTPServer(("127.0.0.1", 8080), Receiver)
print("Listening on http://127.0.0.1:8080/hooks/superpdf")
server.serve_forever()
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
var (
key []byte
mu sync.Mutex
handled = map[string]bool{}
)
// verify checks a Standard Webhooks signature:
// "v1," + base64 HMAC-SHA256 of "id.timestamp.body".
func verify(h http.Header, body []byte) bool {
id, timestamp := h.Get("webhook-id"), h.Get("webhook-timestamp")
sent, err := strconv.ParseInt(timestamp, 10, 64)
if id == "" || err != nil {
return false
}
// Refuse events more than five minutes old (or ahead)
if age := time.Now().Unix() - sent; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%s.", id, timestamp)
mac.Write(body)
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, signature := range strings.Split(h.Get("webhook-signature"), " ") {
if hmac.Equal([]byte(signature), []byte(expected)) {
return true
}
}
return false
}
func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verify(r.Header, body) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
id := r.Header.Get("webhook-id")
mu.Lock()
seen := handled[id]
handled[id] = true
mu.Unlock()
if seen {
return // a retry of an event already handled
}
var event struct {
Type string `json:"type"`
Data struct {
JobID string `json:"job_id"`
Outputs []string `json:"outputs"`
} `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
log.Print(err)
return
}
files := len(event.Data.Outputs)
fmt.Printf("%s %s: %d file(s)\n", event.Type, event.Data.JobID, files)
}
func main() {
secret := os.Getenv("SUPERPDF_WEBHOOK_SECRET")
var err error
key, err = base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
log.Fatal("SUPERPDF_WEBHOOK_SECRET is not a whsec_ secret")
}
listener, err := net.Listen("tcp", "127.0.0.1:8080")
if err != nil {
log.Fatal(err)
}
http.HandleFunc("POST /hooks/superpdf", receive)
fmt.Println("Listening on http://127.0.0.1:8080/hooks/superpdf")
log.Fatal(http.Serve(listener, nil))
}
node receiver.tspython3 receiver.pygo run receiver.goThen start a job with the callback_url above. The receiver prints:
Listening on http://127.0.0.1:8080/hooks/superpdf
job.succeeded job_f5a4c3fc-c43f-4655-8a35-f8821bf7cd4f: 2 file(s)Replay protection
The signature proves an event came from your superPDF. Two more checks stop an old event being sent again:
- Timestamp.
webhook-timestampis signed, so it can’t be changed. Refusing events more than five minutes old means a captured event stops working quickly. - Event id. Every attempt to deliver the same event has the same
webhook-id. Remember the ids you’ve handled (for longer than five minutes) and acknowledge repeats without acting on them again. That also makes retries safe.
Delivery and retries
Your receiver should answer with a 2xx status within 10 seconds; do slow work after answering.
| Attempt | When |
|---|---|
| 1 | As soon as the job finishes |
| 2 | 1 second after attempt 1 |
| 3 | 2 seconds after attempt 2 |
| 4 | 4 seconds after attempt 3 |
| 5 | 8 seconds after attempt 4, the last |
Network errors, timeouts and 408, 429 and 5xx answers are retried. Other answers, including redirects, aren’t. GET /v1/jobs/<id> shows the outcome in callback. If the API stops, deliveries still waiting are dropped.
Allowed addresses
Events go only to this Mac (localhost, 127.0.0.1 or [::1]) unless you start the API with --webhook-allow https://hooks.example.com for each other origin. Addresses with a user name or password are refused with 422. Prefer https for anything that leaves this Mac.