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.
Adding, editing and deleting endpoints, and reading ("Copy signing secret") or regenerating a secret, are administrator-only (listing needs only view access). The rule is the same from the UI and from the API. See Required permissions (workspace administrator) below for the details.
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.
Required permissions (workspace administrator)
Adding, editing and deleting a webhook endpoint, and reading or regenerating its signing secret, all require the workspace administrator (ADMIN) role. Listing endpoints only requires the read (READ) role. Reading the secret is also what the "Copy signing secret" button does, so a non-administrator member can see the endpoint list but gets a 403 when trying to copy a secret.
A webhook endpoint is an outbound channel that sends completion events for PDFs generated anywhere in the workspace to a URL of your choosing — including reports generated by other members. It is therefore treated the same as API key issuance, OAuth client management and notification settings: administrators only.
The rule is the same whether you configure endpoints from the UI or the API. When an OAuth 2.0 integration (Zapier, the Shopify app, …) registers an endpoint automatically as part of connecting, the user who authorizes it must be an administrator. Authorizing with a non-administrator account makes the integration fail with 403.
Connect integrations that rely on webhooks — the Zapier New PDF Generated trigger, the Shopify app's ReportFlow connection — with a workspace administrator account. The consent screen does not show your role, so otherwise you only find out after you have already granted access.
Endpoint management API
You can also manage endpoints via the API instead of the UI.
| Method | Path | Role | Description |
|---|---|---|---|
GET | /workspace/:workspaceId/webhook-endpoints | READ | List endpoints |
POST | /workspace/:workspaceId/webhook-endpoints | ADMIN | Create an endpoint (url, designFilterType; a selected filter with empty/omitted targetDesignIds is normalized to all) |
PATCH | /workspace/:workspaceId/webhook-endpoints/:endpointId | ADMIN | Update an endpoint (url, enabled, designFilterType, targetDesignIds are all optional) |
DELETE | /workspace/:workspaceId/webhook-endpoints/:endpointId | ADMIN | Delete an endpoint |
POST | /workspace/:workspaceId/webhook-endpoints/:endpointId/secret/regenerate | ADMIN | Regenerate the signing secret |
GET | /workspace/:workspaceId/webhook-endpoints/:endpointId/secret | ADMIN | Read the signing secret. This one is first-party login session only and cannot be called with an OAuth access token (see below) |
Requests without the required role return 403. Re-authorizing or reconnecting the same account does not fix it — what lacks the role is the account, not the token, so you get the same result. The fix is to change which account is connected: reconnect with an administrator account, or ask an administrator to set it up.
The older single-URL API (PATCH /workspace/webhook and PATCH /workspace/:workspaceId/webhook) requires the administrator role as well. Both can be called with an OAuth 2.0 access token, so integrations that still use the single-URL form (Make.com, for example) must also be authorized by an administrator.
OAuth scope required by integrations
Separately from the role, calling with an OAuth 2.0 access token requires the pdf:generate scope. That applies to every operation in the table above that OAuth can reach, listing included (GET .../secret is first-party login session only, so it is out of reach regardless of scope). The two single-URL endpoints mentioned above (PATCH /workspace/webhook and PATCH /workspace/:workspaceId/webhook) require the same scope. The endpoint management API configures PDF-completion notifications, so it is guarded by the same scope as generation itself.
The role and the scope are independent conditions and both must hold. Authorizing with an administrator account still returns 403 if the token lacks pdf:generate, and a token that has the scope still returns 403 if the role is insufficient (listing aside). When connecting an integration, authorize it with an administrator account and grant a scope set that includes pdf:generate.
A Client Credentials token gets a 403 on every operation, listing included. That grant issues a token with no end user — its subject is the client itself — so no workspace membership row can be resolved for it, and it fails the workspace access check that runs before the role check. To manage webhook endpoints, use a token obtained through the Authorization Code flow, authorized by an administrator.
What an OAuth integration may operate on
When called with an OAuth 2.0 access token, update, delete and secret regeneration are limited to endpoints the integration itself created. Targeting an endpoint created by a different integration returns 404 (404 rather than 403, so the response does not reveal whether the endpoint exists).
Operations from the UI (Workspace Settings) are not restricted this way: administrators can manage every endpoint in the workspace, including those created by integrations.
Endpoints that existed before this ownership link was introduced have no owner and can be operated on by any integration. An endpoint becomes owned by an integration the first time that integration updates it (PATCH).
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://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(Admin only) - API (read):
GET /workspace/:workspaceId/webhook-endpoints/:endpointId/secret(Admin only, first-party login session only)
GET .../webhook-endpoints/:endpointId/secret is restricted to a first-party login session (the rf_access_token cookie, or an access token issued by logging in). Calling it with an access token issued to an OAuth2 integration returns 403 regardless of the granted scopes.
The reason is that being able to read an existing secret lets an attacker forge webhooks for that endpoint without the receiver ever noticing. POST .../secret/regenerate, by contrast, destroys the old secret, so a stolen secret surfaces as signature-verification failures in the existing integration. That is why regenerate remains callable over OAuth — though it still requires both the administrator role and the pdf:generate scope, and which endpoints it may target is subject to What an OAuth integration may operate on above.
If an integration needs the secret, copy it from the UI, or take it from the secret/regenerate response.
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.
What happens when a delivery fails?
It is retried automatically. Failed deliveries use exponential backoff (1s, then 2s) for up to 3 total attempts, and PDF generation itself still counts as successful even if every attempt fails.
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
Why aren't my webhooks arriving?
Most cases come down to a disabled endpoint, a design filter that doesn't match the generated design, or a URL that isn't HTTPS and publicly reachable. Work through the checks below in order, then look at your endpoint's logs.
- 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