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.

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

Setting up the webhook URL

  1. Open Workspace Settings
  2. Go to the Developer tab
  3. Enter the HTTPS URL that should receive notifications under Webhook URL
  4. 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

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.

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

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

  1. Is the Webhook URL set? Check Workspace Settings > Developer tab.
  2. Is it HTTPS? Plain HTTP URLs are rejected.
  3. Is it publicly reachable? localhost and private IPs are blocked. Use webhook.site for one-off testing.
  4. 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