API Guide Private beta
Webhooks
Submit a conversion, give us a URL, and get a signed notification the moment the job finishes - no polling loop. Every delivery is HMAC-signed, replay-protected and carries no secrets, so your endpoint can trust it and an eavesdropper learns nothing useful.
How it works
- Submit with a URL. Add a
webhook_urlform field to any async job submission. The202response returns the public jobidand the one-time jobkey- store both; the key is what you will verify signatures with. - Receive and verify. When the job finishes or fails we
POSTa JSON event to your URL with anX-PrivConvert-Signatureheader. Verify the signature, deduplicate onevent_id, answer2xxquickly. - Download. Fetch the result with your API key plus the job key in an
X-Job-Keyheader. Results wait up to 30 minutes, encrypted in RAM with a key derived from your job key.
# One extra form field is the entire setup
curl -s -X POST https://privconvert.com/api/jobs/word-to-pdf \
-H "Authorization: Bearer pk_live_…" \
-F "[email protected]" \
-F "webhook_url=https://example.com/privconvert-webhook" The URL must be https on port 443 and publicly reachable; private and internal addresses are refused at submission time. Deliveries originate from Cloudflare network addresses.
The delivery
# POST to your URL
Content-Type: application/json
User-Agent: PrivConvert-Webhooks/1.0
X-PrivConvert-Signature: t=1760000000,v1=609fa0d5493200…
{
"event": "job.finished",
"event_id": "evt_6c0f…",
"id": "job_f3a81c…",
"status": "finished",
"tool": "word-to-pdf",
"created_at": 1760000000,
"output": { "filename": "contract.pdf", "mime": "application/pdf" },
"download": "https://privconvert.com/api/jobs/job_f3a81c…/download",
"expires_at": 1760001800
} | Field | Meaning |
|---|---|
event | job.finished or job.failed. A failed job carries an error object (same shape as the synchronous API) instead of output and download. |
event_id | Unique per event and identical across retry attempts - your idempotency key. |
id | The public job id you received at submission. Look up the matching job key with it. |
download | Where to fetch the result. Requires your API key and the X-Job-Key header, so the payload alone is useless to a third party. |
expires_at | Unix time when the stored result is deleted (30 minutes after finishing). |
Build a receiving endpoint
A complete, production-shaped receiver: exact-bytes signature verification, replay window, deduplication on event_id, fast acknowledgement.
import hashlib
import hmac
import time
from flask import Flask, request
app = Flask(__name__)
# Saved when you submit jobs: job id -> job key (the one-time "pjk_..."
# value from the 202 response). Use your database in production.
JOB_KEYS: dict[str, str] = {}
SEEN_EVENTS: set[str] = set()
def verify_webhook(job_key: str, signature: str, raw_body: bytes,
tolerance_s: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in signature.split(","))
ts, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance_s:
return False # stale timestamp: possible replay
sig_key = hashlib.sha256(b"pc-webhook-sig-v1|" + job_key.encode()).digest()
expected = hmac.new(sig_key, f"{ts}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/privconvert-webhook")
def privconvert_webhook():
raw = request.get_data() # exact bytes: sign THESE, never re-parse
event = request.get_json(silent=True) or {}
job_key = JOB_KEYS.get(event.get("id", ""))
signature = request.headers.get("X-PrivConvert-Signature", "")
if not job_key or not verify_webhook(job_key, signature, raw):
return "", 400
if event["event_id"] in SEEN_EVENTS:
return "", 200 # duplicate delivery: acknowledge, skip
SEEN_EVENTS.add(event["event_id"])
# Acknowledge fast; do real work (e.g. download the result) afterwards,
# for example on a task queue.
if event["event"] == "job.finished":
queue_download(event["id"], job_key) # your code
return "", 200 import express from "express";
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// Saved when you submit jobs: job id -> job key (the one-time "pjk_..."
// value from the 202 response). Use your database in production.
const JOB_KEYS = new Map();
const SEEN_EVENTS = new Set();
function verifyWebhook(jobKey, signature, rawBody, toleranceS = 300) {
const m = signature.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
if (!m) return false;
const ts = parseInt(m[1], 10);
if (Math.abs(Date.now() / 1000 - ts) > toleranceS) return false;
const sigKey = createHash("sha256")
.update("pc-webhook-sig-v1|" + jobKey).digest();
const expected = createHmac("sha256", sigKey)
.update(`${ts}.`).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
// express.raw keeps the exact bytes: sign THESE, never re-serialized JSON.
app.post("/privconvert-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const event = JSON.parse(req.body.toString());
const jobKey = JOB_KEYS.get(event.id);
const signature = req.get("X-PrivConvert-Signature") || "";
if (!jobKey || !verifyWebhook(jobKey, signature, req.body)) {
return res.sendStatus(400);
}
if (SEEN_EVENTS.has(event.event_id)) return res.sendStatus(200);
SEEN_EVENTS.add(event.event_id);
// Acknowledge fast; do real work (e.g. download the result) afterwards.
if (event.event === "job.finished") {
queueDownload(event.id, jobKey); // your code
}
res.sendStatus(200);
});
app.listen(3000); Signature scheme
The X-PrivConvert-Signature header has the form t=<unix timestamp>,v1=<hex hmac>:
- Signing key:
SHA-256("pc-webhook-sig-v1|" + job_key). The derivation keeps the signing key and the result-encryption key cryptographically separate even though both come from the one job key you hold. - Signature:
HMAC-SHA256(signing_key, "<t>." + raw_body)over the exact raw request bytes. Compare constant-time. - Freshness: reject timestamps outside your tolerance (5 minutes recommended). Retries are re-signed with a fresh timestamp, so a tight window never rejects a legitimate retry.
Test vector
Run your implementation against these fixed inputs before going live:
# Inputs
job key pjk_testvector_do_not_use_0000000000000000000
timestamp 1760000000
raw body {"event":"job.finished","event_id":"evt_00000000000000000000000000000000","id":"job_000000000000000000000000","status":"finished","tool":"txt-to-pdf"}
# Expected signature
X-PrivConvert-Signature: t=1760000000,v1=609fa0d5493200864ae983137780d6fa6cfd7946953052fcaf4f6d4b8792803f express.raw() on the webhook route; in Flask, use request.get_data().
Retries, idempotency and failure
- Any
2xxacknowledges delivery. Everything else (including a timeout) triggers up to 4 attempts over roughly 45 seconds. - The body is byte-identical on every attempt and
event_idnever changes, so deduplicating on it makes duplicate deliveries harmless. - A webhook that cannot be delivered never affects the conversion: the result stays stored for the full retention window and polling keeps working, so your fallback path is always
GET /api/jobs/{id}. - One job sends exactly one event:
job.finishedorjob.failed, never both.
Request a beta key, submit a job with a webhook_url, and your endpoint gets its first signed event minutes later.