Skip to content
Porter

Webhooks

Receive results when they are ready.

  1. Open API setup and save a public HTTPS endpoint.
  2. Copy the signing secret into your receiver’s PORTER_WEBHOOK_SECRET environment variable.
  3. Select events and send a test event.
  4. Check the delivery status. A 2xx response acknowledges receipt.
EventMeaning
job.completedAll requested stages finished. A report can still contain accessibility findings.
job.failedProcessing or validation could not finish after recovery attempts.
file.updatedA later successful run became the file’s published output.

Events cover API-created files in your organization, including later manual or consented automatic improvement runs. API uploads default to no automatic improvement reruns and no staff sharing. Test deliveries have type webhook.test and do not process a PDF.

Payload

{
  "id": "event-uuid",
  "type": "job.completed",
  "api_version": "v1",
  "created_at": "2026-10-21T12:00:00+00:00",
  "data": {
    "id": "job-uuid",
    "file_id": "file-uuid",
    "reference": "invoice-1042",
    "mode": "validate_only",
    "status": "completed",
    "trigger": "initial",
    "previous_job_id": null,
    "has_output": false,
    "validation": {
      "status": "completed",
      "failed_checks": 2,
      "failed_rules": 1
    }
  }
}

data contains the job snapshot. Fetch reports and downloads through the API. A new manual or improvement run has a new job ID; transient internal retries keep the original ID.

Improvements days or weeks later

An improvement run is separate from the original job. The original job finishes normally; it does not stay open while waiting for a future improvement. When a later run finishes, it has a new job ID, the same file ID and reference, and the previous job ID. A published replacement also emits file.updated.

To enable this for a fix_and_validate job, send automatic_improvements: true and choose retention: "90" or retention: "keep" if you want to allow improvements weeks later. Automatic improvements are available on every plan that includes API access. There is no promised rerun date; deleted or expired files cannot be improved. Keep the webhook endpoint active for as long as you retain the file.

{
  "upload_id": "your-upload-id",
  "mode": "fix_and_validate",
  "automatic_improvements": true,
  "retention": "90",
  "reference": "invoice-1042"
}

Verify, store, acknowledge

Verify HMAC-SHA256 over timestamp + "." + raw_request_body, using your signing secret. The Porter-Signature header contains t=timestamp,v1=hex_signature. Reject timestamps more than five minutes from your clock. Verify the raw bytes before parsing JSON.

# Python 3. Set PORTER_WEBHOOK_SECRET, then run this file.
# Forward a public HTTPS tunnel to localhost:4242 for local testing.
import hashlib, hmac, json, os, sqlite3, time
from http.server import BaseHTTPRequestHandler, HTTPServer

secret = os.environ["PORTER_WEBHOOK_SECRET"].encode()
db = sqlite3.connect("events.db")
db.execute("CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, body TEXT NOT NULL, processed INTEGER DEFAULT 0)")

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        try:
            length = int(self.headers.get("Content-Length", "0"))
            if not 0 < length <= 1048576: raise ValueError()
            raw = self.rfile.read(length)
            parts = dict(item.split("=", 1) for item in self.headers["Porter-Signature"].split(","))
            timestamp = parts["t"]
            if abs(time.time() - int(timestamp)) > 300: raise ValueError()
            expected = hmac.new(secret, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
            if not hmac.compare_digest(expected, parts["v1"]): raise ValueError()
            event = json.loads(raw)
            if not isinstance(event.get("id"), str): raise ValueError()
        except (ValueError, KeyError, TypeError):
            self.send_response(400); self.end_headers(); return
        # Commit to a durable inbox before acknowledging. Duplicate IDs are harmless.
        with db:
            db.execute("INSERT OR IGNORE INTO events(id,body) VALUES (?,?)", (event["id"], raw.decode()))
        self.send_response(204); self.end_headers()

HTTPServer(("127.0.0.1", 4242), Receiver).serve_forever()

This local receiver commits events to SQLite before returning HTTP 204. Process unhandled rows in your application’s background worker. For local testing, expose port 4242 through a public HTTPS tunnel. Production receivers should use their normal application server and durable queue.

Delivery and recovery

Expect duplicate and out-of-order delivery. Deduplicate by event id; never replace a newer published result just because an older event arrived later. Re-fetch the exact job and compare your stored run identity.

Each new event gets its own delivery window when it is created, even if the document was uploaded weeks earlier. The 72-hour window applies only to delivering that event, not to when a document can be improved. Porter times out delivery after eight seconds and retries failures for up to 72 hours, with backoff from 30 seconds to one hour. Delivery scheduling can add delay. The same event ID is retained across attempts. Use Resend in API setup to retry after repairing your receiver.

Changing the endpoint or subscriptions cancels delivery of events from the earlier configuration. Deleted or expired files are not delivered. Rotate the signing secret when needed and update the receiver. Endpoint failures never change a PDF’s processing outcome.