Skip to content

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.

statusMeaning
queuedWaiting for the jobs ahead of it.
runningRunning now. progress shows how far it is, for tools that report it (merge, split, pipelines and batches).
succeededFinished. result is the same result the tool’s own endpoint returns.
failedFinished 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.json
Finished job
{
  "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:

Terminal
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/superPDF

Without 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"
  }'
Response
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:

Request
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:

  1. Take the key: the secret without its whsec_ prefix, decoded from base64.
  2. Compute HMAC-SHA256 of webhook-id, webhook-timestamp and the request body, joined with dots, and encode it as base64.
  3. Put v1, in front and compare it, in constant time, with each space-separated value in webhook-signature.
  4. Refuse timestamps more than five minutes away from now, and skip a webhook-id you 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:

receiver.ts
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');
});
node receiver.ts

Then start a job with the callback_url above. The receiver prints:

Output
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-timestamp is 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.

AttemptWhen
1As soon as the job finishes
21 second after attempt 1
32 seconds after attempt 2
44 seconds after attempt 3
58 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.