Skip to main content

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 file
  • POST /file/sync/multiple — synchronous multiple files
  • POST /file/async/single — asynchronous single file
  • POST /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

  1. Open Workspace Settings
  2. Go to the Developer tab
  3. Click Add endpoint
  4. Enter the HTTPS URL that should receive notifications
  5. Choose a design filter (see below)
  6. 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:

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

MethodPathDescription
GET/workspace/:workspaceId/webhook-endpointsList endpoints
POST/workspace/:workspaceId/webhook-endpointsCreate an endpoint (url, designFilterType; a selected filter with empty/omitted targetDesignIds is normalized to all)
PATCH/workspace/:workspaceId/webhook-endpoints/:endpointIdUpdate an endpoint (url, enabled, designFilterType, targetDesignIds are all optional)
DELETE/workspace/:workspaceId/webhook-endpoints/:endpointIdDelete 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

FieldTypeDescription
eventstringFixed value: "file.completed"
timestampstringEvent time, ISO 8601
workspaceIdstringWorkspace ID
requestIdstring (UUID)Generation request ID. Use it with the download endpoint
designIdstringDesign ID
versionnumberDesign version number
filesarrayGenerated file entries
files[].fileIdstringFile ID (per-file download endpoint)
files[].fileNamestringFile name (with extension)
files[].passthroughobjectThe value supplied as passthrough on the request (only present when set)
files[].share.shareTypestringShare type (workspace / invited / public)
files[].share.urlstringSharable URL for the file
files[].share.passcodeEnabledbooleanWhether passcode protection is enabled
files[].share.passcodestringServer-generated passcode (only when passcodeEnabled=true AND immediately after generation)
When to use passthrough

ReportFlow 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 from now by 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.parse typically 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

  1. Is a webhook endpoint set up and enabled? Check Workspace Settings > Developer tab (disabled endpoints never receive deliveries).
  2. Does the design filter match? For an endpoint set to selected, confirm the generated design is included in targetDesignIds. If a targeted design was later deleted, it isn't removed automatically — check the list for the "N deleted target(s)" warning badge.
  3. Is it HTTPS? Plain HTTP URLs are rejected.
  4. Is it publicly reachable? localhost and private IPs are blocked. Use webhook.site for one-off testing.
  5. 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