Skip to content
Press.js Press.js Press.js Docs

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).

Every webhook is signed with HMAC-SHA256. The signature uses a signing secret resolved at two levels:

  1. 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.
  2. 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.

Terminal window
# Configure webhook delivery URL and stage deploy-level secret rotation
curl -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"}}'

Fired when a render job succeeds or fails. Event ID: evt_render_job_terminated_{jobId}.

HeaderValue
Content-Typeapplication/json
User-AgentPress-Cloud-Webhooks/1.0
Press-Webhook-Eventrender.job.terminated
Press-Webhook-Idevt_render_job_terminated_{jobId} — stable idempotency key
Press-Webhook-DeliveryDelivery attempt ID
Press-Webhook-Attempt1-based attempt number
Press-Webhook-TimestampUnix seconds
Press-Webhook-Signaturev1={hex} — HMAC-SHA256 signature, always present
{
"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
}
}
}
}
{
"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.

Every webhook request includes a signature. You must verify it. The signature is:

HMAC-SHA256(secret, "{timestamp}.{rawBody}")

Verification steps:

  1. Check Press-Webhook-Timestamp is within ±5 minutes of current time
  2. Build canonical string: "{timestamp}.{rawBody}" (dot as literal separator)
  3. Compute HMAC-SHA256, encode as lowercase hex (64 chars)
  4. Extract v1= value from Press-Webhook-Signature (supports comma-separated list)
  5. Compare with constant-time comparison
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)
}
import hashlib
import hmac
import 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}, 200
AttemptDelay
1immediate
215s
360s
45m
515m
6 (last)30m
  • 10-second request timeout. Total delivery window ~51 minutes.
  • Endpoint Retry-After header is respected (capped at 1 hour).
  • Return 2xx to acknowledge. Return 5xx to retry. Use Press-Webhook-Id for idempotency.

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.

  • 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-Timestamp within a ±5 minute window to prevent replay.
  • Always check Press-Webhook-Id for deduplication.
  • Use a unique, unguessable URL path as an additional layer of defense.