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.
You can register multiple webhook endpoints per workspace. Each endpoint has its own design filter (determining which designs trigger notifications) and its own signing secret, so you can route notifications for different designs (report types) to different destinations with different secrets.
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
If more than one endpoint's design filter matches the generated design, notifications are sent to each matching endpoint independently (the same payload is sent to each). A delivery failure to one endpoint has no effect on delivery to the others.
Setting up webhook endpoints
- Open Workspace Settings
- Go to the Developer tab
- Click Add endpoint
- Enter the HTTPS URL that should receive notifications
- Choose a design filter (see below)
- Click Save
Each workspace can have up to 10 endpoints. Existing endpoints can be toggled on/off, edited, or deleted from the list.
Design filter
Each endpoint can be configured to receive completion events for specific designs:
| Value | Description |
|---|---|
all (all designs) | Notified for every design in the workspace |
selected (selected designs only) | Notified only for the specific designs chosen in the add/edit modal |
If you choose selected but specify no target designs, the endpoint is registered as all (all designs). An endpoint never goes silent unless you actively narrow it to specific designs.
Endpoint management API
You can also manage endpoints via the API instead of the UI.
| Method | Path | Description |
|---|---|---|
GET | /workspace/:workspaceId/webhook-endpoints | List endpoints |
POST | /workspace/:workspaceId/webhook-endpoints | Create an endpoint (url, designFilterType; a selected filter with empty/omitted targetDesignIds is normalized to all) |
PATCH | /workspace/:workspaceId/webhook-endpoints/:endpointId | Update an endpoint (url, enabled, designFilterType, targetDesignIds are all optional) |
DELETE | /workspace/:workspaceId/webhook-endpoints/:endpointId | Delete an endpoint |
If a design referenced in targetDesignIds is later deleted, it is not automatically removed from the endpoint's configuration (the UI shows a "deleted target(s)" warning badge with the count). Review your endpoint configuration after deleting a targeted design to avoid silently losing notifications.
Migrating from the old single-URL setup
Any single webhook URL/secret configured before this multi-endpoint feature was introduced has been automatically carried forward as a "migrated" endpoint (design filter = all). No action is required — you can toggle, edit, delete, or regenerate its secret like any other endpoint.
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.
Top-level string/number values in passthrough are also stored as
report-search metadata (both in the generated PDF's XMP metadata and in the
Re:port Flow app's report-search index). See
passthrough and report-search metadata
for which values are indexed, the limits, and why personal data doesn't belong there.
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_...)
The signing secret is issued per webhook endpoint, not per workspace. Two endpoints in the same workspace have different secrets.
- From the UI: Workspace Settings > Developer tab > "Copy signing secret" on the target endpoint
- API (regenerate):
POST /workspace/:workspaceId/webhook-endpoints/:endpointId/secret/regenerate - API (read):
GET /workspace/:workspaceId/webhook-endpoints/:endpointId/secret
Regenerating immediately invalidates that endpoint's previous secret (other endpoints' secrets are unaffected).
Endpoints without a generated secret don't receive a signature header (see below). A migrated endpoint (carried forward from the old single-URL setup) keeps whatever secret had already been generated before migration.
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
Endpoints for which a webhook secret has not been generated will not receive an X-Report-Flow-Signature header. To enable signing, generate a secret for that endpoint 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
A delivery only counts as successful when your endpoint returns a 2xx status code. Any non-2xx response (4xx and 5xx alike), as well as network errors and timeouts, is treated as a failure and retried.
Failed deliveries are retried with exponential backoff (1s, then 2s), up to 3 total attempts (i.e. at most 2 retries). Even if every attempt fails, PDF generation itself is still considered successful.
Troubleshooting
Webhooks aren't arriving
- Is a webhook endpoint set up and enabled? Check Workspace Settings > Developer tab (disabled endpoints never receive deliveries).
- Does the design filter match? For an endpoint set to
selected, confirm the generated design is included intargetDesignIds. If a targeted design was later deleted, it isn't removed automatically — check the list for the "N deleted target(s)" warning badge. - 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 2xx? Any non-2xx response or timeout counts as a failure and is retried (see Retry behaviour). 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