Webhooks
Once you configure a webhook, Press.js Cloud sends an HTTP POST to your endpoint each time a render job reaches a terminal state (succeeded or failed).
Signing secrets
Section titled “Signing secrets”Every webhook is signed with HMAC-SHA256. The signature uses a signing secret resolved at two levels:
- Deploy-level secret — Enable it from the deploy’s Settings → Webhook section. If configured, this secret is used for webhooks from that deploy. You can reveal it with the eye button and copy it after reveal. Enabling, rotating, or disabling the deploy-level secret is staged locally and only applied when you click Save Settings.
- Account-level secret — Every account has a webhook signing secret, auto-generated on sign-up. It serves as the default when no deploy-level secret is set. Manage it from Settings → Webhooks in the dashboard.
GET /v1/account and GET /v1/deploys/:deployId/settings return only webhook metadata, including a masked preview such as whsec****** when a secret is configured. Full signing secrets are revealed only from the dashboard when you click the eye button.
# Configure webhook delivery URL and stage deploy-level secret rotationcurl -X PATCH "$PRESS_CLOUD_API/v1/deploys/$DEPLOY_ID/settings" \ -H "Authorization: Bearer $PRESS_CLOUD_TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook":{"enabled":true,"url":"https://...","signingSecretAction":"rotate"}}'Event: render.job.terminated
Section titled “Event: render.job.terminated”Fired when a render job succeeds or fails. Event ID: evt_render_job_terminated_{jobId}.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Press-Cloud-Webhooks/1.0 |
Press-Webhook-Event | render.job.terminated |
Press-Webhook-Id | evt_render_job_terminated_{jobId} — stable idempotency key |
Press-Webhook-Delivery | Delivery attempt ID |
Press-Webhook-Attempt | 1-based attempt number |
Press-Webhook-Timestamp | Unix seconds |
Press-Webhook-Signature | v1={hex} — HMAC-SHA256 signature, always present |
Success payload
Section titled “Success payload”{ "id": "evt_render_job_terminated_job_abc123", "type": "render.job.terminated", "occurredAt": 1704110400000, "data": { "job": { "id": "job_abc123", "status": "succeeded", "deployId": "dep_xyz", "deployVersionId": "dv_xyz", "deployVersionNo": 42, "deployVersionRoute": "/invoice", "deployVersionTitle": "Invoice", "businessKey": "__default__", "payloadHash": "a1b2...", "outputMode": "transient", "attempt": 0, "requestedAt": 1704110300000, "startedAt": 1704110350000, "finishedAt": 1704110400000 }, "output": { "id": "out_job_abc123", "renderJobId": "job_abc123", "contentType": "application/pdf", "fileName": "Invoice-job_abc123.pdf", "sha256": "e5f6...", "bytes": 12345, "pageCount": 2, "download": { "url": "https://assets.pressjs.dev/downloads/render-jobs/job_abc123.pdf?token=...", "expiresAt": 1704111000000 } } }}Failure payload
Section titled “Failure payload”{ "id": "evt_render_job_terminated_job_def456", "type": "render.job.terminated", "occurredAt": 1704110500000, "data": { "job": { "id": "job_def456", "status": "failed", "deployId": "dep_xyz", "deployVersionId": "dv_xyz", "businessKey": "__default__", "payloadHash": "b2c3...", "outputMode": "transient", "attempt": 2, "requestedAt": 1704110300000, "finishedAt": 1704110500000, "errorCode": "render_retry_limit_exceeded", "errorMessage": "Automatic render retries were exhausted." } }}data.output is only present on success. data.job.errorCode / data.job.errorMessage only present on failure. The download URL is HMAC-SHA256 signed and valid for ~10 minutes from each delivery attempt.
Signature verification
Section titled “Signature verification”Every webhook request includes a signature. You must verify it. The signature is:
HMAC-SHA256(secret, "{timestamp}.{rawBody}")Verification steps:
- Check
Press-Webhook-Timestampis within ±5 minutes of current time - Build canonical string:
"{timestamp}.{rawBody}"(dot as literal separator) - Compute HMAC-SHA256, encode as lowercase hex (64 chars)
- Extract
v1=value fromPress-Webhook-Signature(supports comma-separated list) - Compare with constant-time comparison
Express
Section titled “Express”import crypto from "node:crypto";
const SECRET = process.env.PRESS_WEBHOOK_SECRET!;const TOLERANCE = 5 * 60; // 5 minutes
app.post( "/webhooks/press", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf-8"); const sig = req.headers["press-webhook-signature"]; const ts = req.headers["press-webhook-timestamp"];
if (typeof sig !== "string" || typeof ts !== "string") { return res.status(400).json({ error: "Missing webhook headers" }); }
// Timestamp check (replay protection) if (Math.abs(Math.floor(Date.now() / 1000) - Number.parseInt(ts, 10)) > TOLERANCE) { return res.status(403).json({ error: "Timestamp outside tolerance" }); }
// Signature check const expected = crypto .createHmac("sha256", SECRET) .update(`${ts}.${rawBody}`) .digest("hex");
const valid = sig .split(",") .map((p) => p.trim()) .filter((p) => p.startsWith("v1=")) .map((p) => p.slice(3)) .some((s) => crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
if (!valid) { return res.status(403).json({ error: "Invalid signature" }); }
// Acknowledge immediately, process async res.status(200).json({ received: true }); processWebhook(JSON.parse(rawBody)).catch(console.error); });func webhookHandler(w http.ResponseWriter, r *http.Request) { rawBody, _ := io.ReadAll(io.LimitReader(r.Body, 65536)) sig := r.Header.Get("Press-Webhook-Signature") ts := r.Header.Get("Press-Webhook-Timestamp")
if sig == "" || ts == "" { http.Error(w, "Missing headers", 400) return } // Timestamp check t, _ := strconv.ParseInt(ts, 10, 64) if math.Abs(float64(time.Now().Unix()-t)) > 300 { http.Error(w, "Outside tolerance", 403) return } // Signature check mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(fmt.Sprintf("%s.%s", ts, rawBody))) expected := hex.EncodeToString(mac.Sum(nil)) for _, p := range strings.Split(sig, ",") { if s, ok := strings.CutPrefix(strings.TrimSpace(p), "v1="); ok { if subtle.ConstantTimeCompare([]byte(s), []byte(expected)) == 1 { w.WriteHeader(200) w.Write([]byte(`{"received":true}`)) go processWebhook(rawBody) return } } } http.Error(w, "Invalid signature", 403)}Python (Flask)
Section titled “Python (Flask)”import hashlibimport hmacimport time
SECRET = "whsec_...".encode()TOLERANCE = 300
@app.route("/webhooks/press", methods=["POST"])def webhook(): raw_body = request.get_data(as_text=True) sig = request.headers.get("Press-Webhook-Signature", "") ts = request.headers.get("Press-Webhook-Timestamp", "")
if not sig or not ts: return {"error": "Missing headers"}, 400 if abs(int(time.time()) - int(ts)) > TOLERANCE: return {"error": "Timestamp outside tolerance"}, 403
expected = hmac.new(SECRET, f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest() valid = any( hmac.compare_digest(p.strip()[3:], expected) for p in sig.split(",") if p.strip().startswith("v1=") ) if not valid: return {"error": "Invalid signature"}, 403
return {"received": True}, 200Retry behavior
Section titled “Retry behavior”| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 15s |
| 3 | 60s |
| 4 | 5m |
| 5 | 15m |
| 6 (last) | 30m |
- 10-second request timeout. Total delivery window ~51 minutes.
- Endpoint
Retry-Afterheader is respected (capped at 1 hour). - Return 2xx to acknowledge. Return 5xx to retry. Use
Press-Webhook-Idfor idempotency.
Transient vs managed output
Section titled “Transient vs managed output”Webhook retries can outlast the availability window of transient outputs.
Transient (outputMode: "transient"): the PDF file is guaranteed available for 10 minutes after finishedAt (accessGuaranteedUntil in the payload). A cleanup job permanently deletes the file shortly after that cutoff. Each delivery attempt refreshes the download URL’s token, but the underlying file is gone once accessGuaranteedUntil passes. Retries 5–6 (at ~21 and ~51 minutes) will receive a valid download URL that returns a 404 or 410.
Managed (outputMode: "managed"): the PDF is persisted as a managed asset. There is no automatic expiry — accessGuaranteedUntil is absent from the payload, and the download URL works as long as the asset exists. Managed outputs are only deleted when you explicitly remove them.
If webhook delivery reliability matters, use managed output mode. With transient, you must process the webhook within the first ~4 retry attempts (~6 minutes total) or risk losing access to the file.
Security requirements
Section titled “Security requirements”- HTTPS is mandatory for production. Plain
http://is only accepted for local development. - Always validate the signature. Every webhook is signed — reject any request without a valid
Press-Webhook-Signature. - Manage your account signing secret in Settings → Webhooks. Reveal it only when copying, and rotate it periodically.
- Always check
Press-Webhook-Timestampwithin a ±5 minute window to prevent replay. - Always check
Press-Webhook-Idfor deduplication. - Use a unique, unguessable URL path as an additional layer of defense.