Webhooks
Receive completion events with webhooks.
Runs long operations, such as workflows on large files or batches of up to 50 PDFs, as background jobs on the PDF API, and sends a signed webhook to an address you choose when each one finishes. Events never include document contents.
How to use it
- Start the API with a webhook secret. Your receiver uses the same secret to check that events come from superPDF.
- Send
POST /v1/jobswith the tool name, its input and acallback_url. - When the job finishes, superPDF sends a signed event to that address. Check the signature, answer with a 2xx status, and fetch the job if you need its full result.
Start the API with a webhook secret
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 each time it starts and prints it. Export the same value in the Terminal window where your receiver runs.
Start a job
Add callback_url to the job. The TypeScript, Python and Go examples use the client file.
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)
}
Events
When the job finishes, superPDF sends one POST to callback_url, signed in the Standard Webhooks format:
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"
]
}
}Verify events
These receivers check the signature and the timestamp, skip repeats, 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))
}
Jobs and webhooks explains each step, replay protection, retries and the addresses events may go to.
Privacy
Runs on your computer. The superpdf command-line tool reads and writes files only in the folders you allow, and uploads nothing.