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 templates trigger notifications) and its own signing secret, so you can route notifications for different templates 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
When the generation request does not specify webhookUrl and more than one endpoint's design filter matches the generated template, notifications are sent to each matching endpoint independently (the same payload is sent to each). When webhookUrl is specified, only that endpoint is notified. 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 templates:
| Value | Description |
|---|---|
all (all designs) | Notified for every template in the workspace |
selected (selected designs only) | Notified only for the specific templates chosen in the add/edit modal |
If you choose selected but specify no target templates, the endpoint is registered as all (all designs). An endpoint never goes silent unless you actively narrow it to specific templates.
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 Viewer (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 PDFs 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 any integration still using the single-URL form must also be authorized by an administrator (Make.com has since migrated to the endpoint-management API — see Make integration).
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 template 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 template 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 (file.completed) is POSTed. It goes to every endpoint whose design filter matches when the generation request did not specify webhookUrl, and only to that endpoint when it did.
Payload format
{
"event": "file.completed",
"timestamp": "2026-02-15T10:30:45.123Z",
"workspaceId": "ws_abc123",
"requestId": "kZ3mP9qL2xV7nR4t",
"designId": "0eUDdgAjNXrrItA2",
"version": 5,
"files": [
{
"fileId": "Hn4sT8wQ1cV6bX2e",
"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 | Generation request ID (16-character alphanumeric). Use it with the download endpoint |
designId | string | Template ID |
version | number | Template version number |
files | array | Generated file entries |
files[].fileId | string | File ID (per-file download endpoint) |
files[].fileName | string | The file name exactly as sent in the generation request (.pdf is not appended) |
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) |
passthroughRe:port Flow 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.
Per-request destination (webhookUrl)
If you put webhookUrl in the body of a generation request (any of the four endpoints above), the notifications for that generation go only to that one endpoint.
{
"designId": "0eUDdgAjNXrrItA2",
"version": 1,
"content": { "fileName": "invoice", "params": { "amount": 10000 } },
"webhookUrl": "https://example.com/webhooks/report-flow"
}
- Only URLs starting with
https://are accepted (up to 2048 characters). A malformed value returns 400VALIDATION_FAILED. - It must match the URL of an endpoint that is registered and enabled in the workspace. Otherwise (not registered, or disabled) the request returns 400
WEBHOOK_URL_NOT_ALLOWEDand nothing is generated. Arbitrary URLs are not allowed, so register the endpoint first. - When it is given, notifications go only to that endpoint, regardless of design filters, and not to any other endpoint. They are signed with that endpoint's secret.
- When it is omitted,
file.completedgoes to every enabled endpoint whose design filter matches, as before. - The failure event (
file.failed) is sent only whenwebhookUrlis given.
Generation failure event (file.failed)
Sent when an async generation (POST /file/async/single, POST /file/async/multiple) fails — only when the generation request specified webhookUrl, and only to that one URL. Generations without webhookUrl send file.failed to no endpoint at all (existing receivers may treat files[] as completed without looking at event). Sync generation failures are returned in the HTTP response and are not notified.
| Situation | error.code | files |
|---|---|---|
| A single request failed | The reason (e.g. PARAMS_TYPE_MISMATCH, INTERNAL_ERROR) | That one file |
| Every file of a multiple request failed | FILE_GENERATION_FAILED | All failed files (each with its own reason) |
| Only some files of a multiple request failed | PARTIAL_FAILURE | Only the failed files (the successful ones arrive as file.completed) |
| The whole generation stopped with an exception after the request was accepted | The reason | Every file of the request |
Payload format
{
"event": "file.failed",
"timestamp": "2026-02-15T10:30:46.456Z",
"workspaceId": "ws_abc123",
"requestId": "kZ3mP9qL2xV7nR4t",
"designId": "0eUDdgAjNXrrItA2",
"version": 5,
"error": {
"code": "PARTIAL_FAILURE",
"message": "1 of 3 files failed to generate."
},
"files": [
{
"fileId": "Rt7uY2iO9pA4sD6f",
"fileName": "invoice_002",
"passthrough": { "invoiceId": "INV-002" },
"error": {
"code": "PARAMS_TYPE_MISMATCH",
"message": "Parameter validation failed: \"amount\" must be a number"
}
}
]
}
| Field | Type | Description |
|---|---|---|
event | string | Fixed value: "file.failed" |
timestamp | string | Event time, ISO 8601 |
workspaceId / requestId / designId / version | Same as file.completed | |
error.code | string | The reason the request failed (see Error Codes). PARTIAL_FAILURE when only some files failed |
error.message | string | Description of the failure (English) |
files[].fileId | string | The file ID returned in the 202 response |
files[].fileName | string | The file name sent in the generation request |
files[].passthrough | object | The passthrough value from the request (only when set). Use it to match failures to your inputs |
files[].error | object | That file's failure reason (code / message, English) |
Files that failed have no share. Signature headers and retries work the same as for file.completed, so return a 2xx for file.failed too (anything else is retried). The same reasons are available from error / failedFiles of the status API.
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 PDF 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());
if (payload.event === 'file.failed') {
// Only sent for generations that specified webhookUrl. Return 2xx, or it is retried
console.error(`Generation failed: ${payload.error.code}`, payload.files);
return res.status(200).send('OK');
}
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)
if payload.get('event') == 'file.failed':
# Only sent for generations that specified webhookUrl. Return 2xx, or it is retried
print(f"Generation failed: {payload['error']['code']}", payload.get('files'))
return 'OK', 200
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
Re:port Flow 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 template, 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 template is included intargetDesignIds. If a targeted template 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
- PDF Download — downloading the generated files