Webhook Notifications
You can receive automatic webhook notifications when PDF generation is complete. This eliminates the need for polling and enables real-time detection of completion, allowing you to automate workflows such as email sending or downstream pipelines.
Overview
Webhook notifications are sent for all PDF generation endpoints:
POST /file/sync/single— synchronous single filePOST /file/sync/multiple— synchronous multiple filesPOST /file/async/single— asynchronous single filePOST /file/async/multiple— asynchronous multiple files
Setting up the webhook URL
- Open Workspace Settings
- Go to the Developer tab
- Enter the HTTPS URL that should receive notifications under Webhook URL
- Click Save
Webhook payload
When PDF generation finishes, the following payload is POSTed to the configured URL.
Payload format
{
"event": "file.completed",
"timestamp": "2026-02-15T10:30:45.123Z",
"workspaceId": "ws_abc123",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"designId": "design_123",
"version": 5,
"files": [
{
"fileId": "7f3d1a2b-4c5e-6f7a-8b9c-0d1e2f3a4b5c",
"fileName": "invoice.pdf",
"passthrough": { "pageId": "abc123" },
"share": {
"shareType": "workspace",
"url": "https://app.re-port-flow.com/file/{requestId}/{fileId}",
"passcodeEnabled": false
}
}
]
}
Field descriptions
| Field | Type | Description |
|---|---|---|
event | string | Fixed value: "file.completed" |
timestamp | string | Event time, ISO 8601 |
workspaceId | string | Workspace ID |
requestId | string (UUID) | Generation request ID. Use it with the download endpoint |
designId | string | Design ID |
version | number | Design version number |
files | array | Generated file entries |
files[].fileId | string | File ID (per-file download endpoint) |
files[].fileName | string | File name (with extension) |
files[].passthrough | object | The value supplied as passthrough on the request (only present when set) |
files[].share.shareType | string | Share type (workspace / invited / public) |
files[].share.url | string | Sharable URL for the file |
files[].share.passcodeEnabled | boolean | Whether passcode protection is enabled |
files[].share.passcode | string | Server-generated passcode (only when passcodeEnabled=true AND immediately after generation) |
passthroughReportFlow does not echo back params (the data used to render the
PDF) on responses or webhooks, both for payload size and to avoid leaking
business data to webhook endpoints.
If you need to know which business record a PDF corresponds to, put your
own DB id (or any opaque token) into passthrough on the request. The
exact value comes back on the response and the webhook unchanged.
{
"fileName": "invoice.pdf",
"passthrough": { "invoiceId": "INV-001", "tenantId": "acme" },
"params": { "customerName": "John Doe", "amount": 10000 }
}
When the webhook arrives, look up your DB by invoiceId to find the
record to update. params (the customer name, amount, etc.) is never
sent off-server.
Downloading the PDF
Use the values from the webhook payload with the download endpoint:
- ZIP for the whole request:
GET /v1/file/download/{requestId} - Individual file:
GET /v1/file/download/{requestId}/{fileId}
See File Download for the response format and authentication.
Implementation examples
Node.js (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
// Keep the raw body so the HMAC signature can be verified
app.use(express.raw({ type: 'application/json' }));
const SECRET = process.env.REPORT_FLOW_WEBHOOK_SECRET;
app.post('/webhook', (req, res) => {
const sigHeader = req.header('X-Report-Flow-Signature') || '';
const parts = Object.fromEntries(
sigHeader
.split(',')
.map((kv) => kv.split('='))
.filter(([, v]) => v !== undefined),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) {
return res.status(400).send('Missing signature components');
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('Stale timestamp');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${req.body.toString()}`)
.digest('hex');
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
if (
expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)
) {
return res.status(400).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
console.log(`Verified webhook: ${payload.files.length} file(s) ready`);
// Download via /v1/file/download/{requestId}/{fileId} as needed
res.status(200).send('OK');
});
app.listen(3000);
Python (Flask)
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['REPORT_FLOW_WEBHOOK_SECRET']
@app.post('/webhook')
def webhook():
raw = request.get_data() # raw bytes; do NOT decode
sig_header = request.headers.get('X-Report-Flow-Signature', '')
parts = dict(
kv.split('=', 1) for kv in sig_header.split(',') if '=' in kv
)
timestamp, signature = parts.get('t'), parts.get('v1')
if not timestamp or not signature:
abort(400, 'Missing signature components')
if abs(time.time() - int(timestamp)) > 300:
abort(400, 'Stale timestamp')
signed_payload = f'{timestamp}.'.encode() + raw
expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(400, 'Invalid signature')
payload = json.loads(raw)
print(f"Verified webhook: {len(payload['files'])} file(s) ready")
return 'OK', 200
Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = os.Getenv("REPORT_FLOW_WEBHOOK_SECRET")
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
parts := map[string]string{}
for _, kv := range strings.Split(r.Header.Get("X-Report-Flow-Signature"), ",") {
if i := strings.Index(kv, "="); i > 0 {
parts[kv[:i]] = kv[i+1:]
}
}
if parts["t"] == "" || parts["v1"] == "" {
http.Error(w, "missing signature components", 400)
return
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
http.Error(w, "invalid timestamp", 400)
return
}
if diff := time.Now().Unix() - ts; diff > 300 || diff < -300 {
http.Error(w, "stale timestamp", 400)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal returns false safely on length mismatch
if !hmac.Equal([]byte(expected), []byte(parts["v1"])) {
http.Error(w, "invalid signature", 400)
return
}
w.WriteHeader(200)
}
Security best practices
1. Use HTTPS URLs
Webhook URLs must use HTTPS. HTTP URLs are rejected.
2. Verify the HMAC-SHA256 signature
ReportFlow signs every webhook payload with HMAC-SHA256. Verifying the signature on your side prevents spoofed requests from third parties.
Getting the signing secret (whsec_...)
Each workspace has its own webhook signing secret.
- From the UI: Workspace Settings > Developer tab
- API (regenerate):
POST /workspace/:workspaceId/webhook/secret/regenerate - API (read):
GET /workspace/:workspaceId/webhook/secret
Regenerating immediately invalidates the previous secret.
Signature header format
X-Report-Flow-Signature: t=1739610645,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t: Unix timestamp (seconds). To prevent replay, recipients should reject timestamps that differ fromnowby more than 5 minutes.v1: HMAC-SHA256 hex digest. The signed string is<t>.<rawBody>(the timestamp, a literal., and the raw request body).
Important: The signed input is the raw request body before any JSON parsing. Re-serializing after
JSON.parsetypically changes key order and whitespace, which breaks signature verification.
Backward compatibility
Workspaces that have not generated a webhook secret will not receive an X-Report-Flow-Signature header. To enable signing, generate a secret in the workspace settings first.
3. Respond quickly
Endpoints should return within 5 seconds. Defer heavy work (email, DB writes, etc.) to a background queue.
4. Don't put credentials in the URL
Do not put authentication tokens or secrets in the webhook URL's query string. Use a header or out-of-band configuration.
Retry behaviour
ReportFlow retries failed deliveries based on the response status:
- 200-299: success (no retry)
- 400-499: client error (no retry)
- 500-599: server error (retry)
Retries happen up to 3 times. Even if all retries fail, PDF generation itself is still considered successful.
Troubleshooting
Webhooks aren't arriving
- Is the Webhook URL set? Check Workspace Settings > Developer tab.
- Is it HTTPS? Plain HTTP URLs are rejected.
- Is it publicly reachable?
localhostand private IPs are blocked. Use webhook.site for one-off testing. - Does the endpoint return 200? Non-2xx responses trigger retry. Check your server logs.
If it still doesn't arrive, please contact support.
Next steps
- Async Workflows — bulk generation patterns that rely on webhooks
- Error Handling — how to handle errors
- File Download — downloading the generated files