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

  1. Submit with a URL. Add a webhook_url form field to any async job submission. The 202 response returns the public job id and the one-time job key - store both; the key is what you will verify signatures with.
  2. Receive and verify. When the job finishes or fails we POST a JSON event to your URL with an X-PrivConvert-Signature header. Verify the signature, deduplicate on event_id, answer 2xx quickly.
  3. Download. Fetch the result with your API key plus the job key in an X-Job-Key header. 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
}
FieldMeaning
eventjob.finished or job.failed. A failed job carries an error object (same shape as the synchronous API) instead of output and download.
event_idUnique per event and identical across retry attempts - your idempotency key.
idThe public job id you received at submission. Look up the matching job key with it.
downloadWhere 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_atUnix time when the stored result is deleted (30 minutes after finishing).
No secrets travel in webhooks. The payload never contains file content and never contains your job key. Anything that logs your webhook traffic learns only that a job with a public id finished.

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

Signature scheme

The X-PrivConvert-Signature header has the form t=<unix timestamp>,v1=<hex hmac>:

  1. 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.
  2. Signature: HMAC-SHA256(signing_key, "<t>." + raw_body) over the exact raw request bytes. Compare constant-time.
  3. 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
Common pitfall. Sign the raw body bytes exactly as received. Parsing the JSON and re-serializing it will reorder or respace fields and the signature will not match. In Express, mount express.raw() on the webhook route; in Flask, use request.get_data().

Retries, idempotency and failure

  • Any 2xx acknowledges 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_id never 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.finished or job.failed, never both.
Ready to build?

Request a beta key, submit a job with a webhook_url, and your endpoint gets its first signed event minutes later.

Request API access