ZettaSend Webhooks
Workspace webhooks let you run your own logic on every ZettaSend transfer. A workspace administrator configures an endpoint URL, and the desktop app calls it twice for every transfer that arrives in the workspace: once before it starts, where you can run your own validation rules and approve or reject the transfer or individual top-level items, and once after approved items arrive, where you can trigger your own post-transfer pipeline. Your endpoint can return markdown error messages that are shown inside the app on both ends.
Overview
The webhook is a plain POST request with a JSON body. No SDK or special
client is required, any HTTP server can receive it. The request carries transfer metadata only
(emails, file names, sizes, paths, timestamps). File contents never leave the encrypted peer-to-peer
channel and are never sent to your endpoint.
- One endpoint URL per workspace, configured by the administrator.
- Quick Transfer webhooks run on the receiver's machine. Hub webhooks run on the project creator's machine for incoming uploads. Hub downloads do not run webhooks.
- A pre-transfer call before approval, and a post-transfer call after the approved files have been received. A blocked transfer has no post-transfer call.
- The pre-transfer hook is a hard gate: only approved items are sent. A full rejection or an unavailable approval blocks all data.
Configuration
- Sign in to the Team Admin Dashboard (Team plan required).
- Go to Workspace Settings → Webhook Endpoint.
- Enter the full URL of your endpoint, e.g.
https://example.com/hooks/zettasend. - Save. Leave the field empty to disable webhooks.
The URL applies to every workspace member automatically. The receiver's desktop app must be able to reach the URL over the network. The app refreshes workspace settings periodically, so a URL change is picked up automatically without a manual restart.
If your webhook server runs locally, use
Cloudflare Tunnel
or ngrok
to expose it through a public HTTPS URL. Enter that URL plus your webhook route in
Webhook Endpoint, for example https://your-tunnel-host/zettasend/transfers
for the example receiver below.
Keep both your server and the tunnel running, and update the endpoint if the tunnel URL changes. The update propagates
to the desktop clients in a few minutes automatically.
Hook types
| Hook | When it fires | What you can do |
|---|---|---|
| Pre-transfer webhook_type: "pre" |
Before a single byte is written to disk. | Run sanity checks: file types, sizes, batch total, sender, destination path, disk space. Return accept to approve the transfer, optionally with rejected_items to exclude whole top-level items. Return reject to block the entire transfer. |
| Post-transfer webhook_type: "post" |
After all approved files are written and verified. | Trigger downstream work: notifications, logging, post-processing jobs. A reject cannot undo the transfer, but it flags the delivery with a markdown message. |
Webhook rejection explanations are rendered as Markdown. For a partial pre-transfer rejection, the sender sees the rejected top-level item list followed by your explanation, while approved items continue transferring. Approval delivery failures use a generic message that does not reveal the endpoint or technical error details.
Request format
The app sends POST to your configured URL with
Content-Type: application/json; charset=utf-8. The only headers sent are
Host, Content-Type and
Content-Length, no authentication headers. Treat the endpoint as
public and protect it yourself (for example a secret token in the URL path or an IP allowlist).
Example payload
A production house offers seven episode folders to a VFX studio. The receiver below approves
four and rejects three whose files violate its shot naming convention. The pre hook describes
all seven offered items. This is a hub upload, so it includes
project_id; quick transfers omit that field.
{
"webhook_type": "pre",
"transfer_type": "hub",
"project_id": "ff7f52fb-ad64-44f0-9526-9b7a368dd08c",
"sender_email": "[email protected]",
"receiver_email": "[email protected]",
"file_count": 7,
"total_bytes": 429916160,
"top_level_items": [
"ep101",
"ep102",
"ep103",
"ep104",
"ep105",
"ep106",
"ep107"
],
"occurred_at_utc": "2026-10-04T10:00:00+00:00",
"destination_path": "D:\\VFX\\Incoming",
"manifest": {
"files": [
{
"relative_path": "ep101\\sc010_010_v001.dpx",
"length": 104857600
},
{
"relative_path": "ep102\\plates\\plate_102_10.mov",
"length": 31457280
},
{
"relative_path": "ep103\\sc030_010_v001.dpx",
"length": 104857600
},
{
"relative_path": "ep104\\preview.mov",
"length": 20971520
},
{
"relative_path": "ep105\\sc050_010_v001.dpx",
"length": 104857600
},
{
"relative_path": "ep106\\temp.mov",
"length": 20971520
},
{
"relative_path": "ep107\\sc070_010_v001.mov",
"length": 41943040
}
]
}
}
Fields
| Field | Type | Description |
|---|---|---|
| webhook_type | string | "pre" before the transfer starts, "post" after it completes. |
| transfer_type | string | "quick" for one-off transfers, "hub" for transfers initiated from a hub project. |
| project_id | string (optional) | Present only when transfer_type is "hub": the id of the hub project the transfer belongs to. |
| sender_email | string | Email of the user sending the files. |
| receiver_email | string | Email of the user receiving the files (whose machine is calling your endpoint). |
| file_count | integer | Number of files. Folders are expanded, every file inside counts individually. |
| total_bytes | integer | Sum of all file sizes in bytes. Useful for batch-size and disk-space checks. |
| occurred_at_utc | string | ISO 8601 timestamp in UTC, e.g. 2026-08-31T22:04:45.6421905+00:00. |
| destination_path | string | Absolute folder on the receiver's machine where files are written, e.g. D:\VFX\Incoming. |
| top_level_items | array of strings | Distinct top-level names in this payload. For the pre hook, these are the exact, case-sensitive names you may return in rejected_items. A folder includes all its descendants and empty directories. Post-hook names can reflect finalized folder names. |
| manifest.files[] | array | One entry per file: relative_path and length. |
| relative_path | string | Path relative to destination_path. May contain subdirectories. |
| length | integer | File size in bytes. |
Assembling full paths
Join destination_path + relative_path to
reconstruct the file's location on the receiver's disk. Folder hierarchies are preserved.
| destination_path | relative_path | Full path |
|---|---|---|
| D:\VFX\Incoming | ep101\sc010_010_v001.dpx | D:\VFX\Incoming\ep101\sc010_010_v001.dpx |
| D:\VFX\Incoming | ep101\plates\plate_101_10.mov | D:\VFX\Incoming\ep101\plates\plate_101_10.mov |
| /mnt/storage/inbox | clients/acme/v02/master.mov | /mnt/storage/inbox/clients/acme/v02/master.mov |
Response format
Return a verdict as JSON. A full or partial rejection can carry an
error field with Markdown, which is rendered inside the app
on both ends (headings, bold, lists, code, and links are supported).
Accept
{
"status": "accept"
}
Reject individual top-level items
In a pre-transfer response, return status: "accept"
with rejected_items. Every unlisted top-level item is approved.
For the example request, this rejects ep102,
ep104, and ep106.
The transfer becomes the four approved folders: ep101,
ep103, ep105,
and ep107:
{
"status": "accept",
"rejected_items": ["ep102", "ep104", "ep106"],
"error": "## Naming convention violation\n\nRejected items contain files that do not match `sc###_###_v###`.\n\nRe-export with correct shot names and resend those items."
}
- Use exact, case-sensitive names from the request's
top_level_itemsarray. Return"ep102", not a nested path such as"ep102/plates/clip.mov". - Rejecting a folder excludes every file and empty directory inside it. Individual descendants cannot be rejected independently.
- Omitting
rejected_items, or returningnullor an empty array, approves every item. Duplicate names are counted once. - Listing every top-level item blocks the transfer. No empty transfer or post hook starts.
status: "reject"blocks the entire transfer. Usestatus: "accept"for full or partial approval.- Unknown names, nested paths, or an invalid list make approval fail and block all data. Item selection is unavailable for post-transfer hooks; a nonempty
rejected_itemslist in a post response is invalid.
The app generates a Rejected items list and displays it before your optional Markdown
error. Quick Transfer keeps the notice in its existing transfer widget;
Hub shows a warning Markdown dialog. The approved items continue transferring normally.
Progress, file counts, audit records, and the post-hook manifest describe only the accepted items.
The post hook's paths point at their actual finalized locations, including any renamed or enclosing folder.
A resumed transfer keeps its original accepted subset and does not repeat a successful pre-check. Starting a fresh transfer runs approval again.
Post payload after partial approval
When those four approved folders finish transferring, the post hook contains four files totaling
356,515,840 bytes. The rejected folders are absent from both
top_level_items and the manifest. After a resume, the same saved
approved batch reaches the post hook once the transfer finishes.
{
"webhook_type": "post",
"transfer_type": "hub",
"project_id": "ff7f52fb-ad64-44f0-9526-9b7a368dd08c",
"sender_email": "[email protected]",
"receiver_email": "[email protected]",
"file_count": 4,
"total_bytes": 356515840,
"top_level_items": [
"ep101",
"ep103",
"ep105",
"ep107"
],
"occurred_at_utc": "2026-10-04T10:02:00+00:00",
"destination_path": "D:\\VFX\\Incoming",
"manifest": {
"files": [
{
"relative_path": "ep101\\sc010_010_v001.dpx",
"length": 104857600
},
{
"relative_path": "ep103\\sc030_010_v001.dpx",
"length": 104857600
},
{
"relative_path": "ep105\\sc050_010_v001.dpx",
"length": 104857600
},
{
"relative_path": "ep107\\sc070_010_v001.mov",
"length": 41943040
}
]
}
}
Reject with explanation
{
"status": "reject",
"error": "## Naming convention violation\n\nThe following files do not match the\n`sc###_###_v###` convention:\n\n- `ep102\\plates\\plate_102_10.mov`"
}
Rejecting without an error field (or with an empty one) produces a generic
pre-transfer rejection with a generated rejected-item list and a generic explanation, or a generic post-transfer review notice.
How the app interprets your response
| Your response | Result |
|---|---|
| 200 + {"status": "accept"} | Approved. Pre-transfer: the transfer proceeds. Post-transfer: success. |
| 200 + {"status": "accept", "rejected_items": [...]} | Pre-transfer only: listed top-level items are rejected and every other item proceeds. An optional error follows the generated rejected-item list. |
| 200 + {"status": "reject"} | Pre-transfer: all items rejected, with a generated list and generic explanation. Post-transfer: a generic review notice; the received files remain in place. |
| 200 + {"status": "reject", "error": "..."} | Rejected; your markdown is rendered in the app on both ends. |
| 2xx with empty body | Delivery failure: no approval was received. The pre-transfer hook blocks the transfer. |
| 2xx with non-JSON body | Delivery failure, no verdict can be read. |
| Non-2xx (4xx / 5xx) | Delivery failure. |
| Reject with error > 65,536 bytes | Invalid response. Oversized explanations are not forwarded; pre-transfer approval fails. The generated list and explanation must also fit the peer's encoded 65,536-byte message limit, so keep responses concise. |
Timeouts
| Phase | Budget |
|---|---|
| Establishing the HTTP connection | 10 seconds (DNS, TCP, TLS handshake). If the connection isn't accepted in time, the delivery fails. |
| Processing on your end | No timeout. Once the request is sent, the app waits as long as your server needs to respond. |
Delivery failures (connect timeout, non-2xx, unparseable body):
- Pre-transfer: the transfer is blocked. The sender sees: “Your transfer couldn't be approved due to a webhook failure on your peer's side. Please try again.” The receiving side sees: “The transfer couldn't be approved due to a webhook failure on your side. Please try again.” The endpoint URL, HTTP details, and exception text are not shown.
- Post-transfer: the files have already arrived. The receiver gets a generic processing-failure notice and the sender completes normally. An explicit post-hook rejection still produces a non-blocking Markdown review notice.
Example receiver
A Flask receiver with example post-production logic. The pre-transfer hook enforces the studio's shot naming convention and rejects each top-level item containing a file that does not match, while accepting the remaining items. With the request above, it rejects ep102, ep104, and ep106. The post-transfer hook logs only the approved delivery and can start downstream automation. Any language or framework works; this endpoint receives a JSON POST.
Save the code as webhook-example-receiver.py, then run:
pip install flask
python webhook-example-receiver.py
The endpoint is http://127.0.0.1:9000/zettasend/transfers.
For a configurable test receiver, run python webhook-tester.py --reject-first 3.
It rejects the first three offered top-level items and logs every request. With seven items,
check that the pending transfer contains four; resume sends no additional pre request or rejection notice.
The post request lists only the approved items.
import re
from pathlib import Path, PurePosixPath
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
# Every file must start with a shot name like sc010_010_v001.
SHOT_PATTERN = re.compile(r"^sc\d{3}_\d{3}_v\d{3}\.")
def relative_path(file):
path = PurePosixPath(file["relative_path"].replace("\\", "/"))
if not path.parts or path.is_absolute() or ".." in path.parts or ":" in path.parts[0]:
abort(400, description="Expected a relative file path.")
return path
@app.route("/zettasend/transfers", methods=["POST"])
def transfers():
payload = request.get_json()
# Empty-directory placeholders are metadata, not files to check or process.
files = [file for file in payload["manifest"]["files"]
if not file["relative_path"].lower().endswith(".zettasend-temp")]
paths = [relative_path(file) for file in files]
if payload["webhook_type"] == "pre":
# One invalid descendant rejects its whole top-level folder.
bad_items = {path.parts[0] for path in paths if not SHOT_PATTERN.match(path.name)}
rejected = [name for name in payload["top_level_items"] if name in bad_items]
approved = [name for name in payload["top_level_items"] if name not in bad_items]
app.logger.warning("[approval] approved=%s rejected=%s", approved, rejected)
if rejected:
return jsonify({
"status": "accept",
"rejected_items": rejected,
"error": "## Naming convention violation\n\n"
"Rejected items contain files that do not match `sc###_###_v###`.\n\n"
"Re-export with correct shot names and resend those items."
}), 200
return jsonify({"status": "accept"}), 200
if payload["webhook_type"] != "post":
abort(400, description="Expected a pre or post webhook.")
# Post contains only approved items, using their actual finalized locations.
destination = Path(payload["destination_path"])
delivered = [destination.joinpath(*path.parts) for path in paths]
app.logger.warning("[delivery] %d files written under %s: %s", len(delivered), destination, delivered)
# Trigger downstream work here, e.g. register these files in your asset database.
return jsonify({"status": "accept"}), 200
if __name__ == "__main__":
app.run(host="127.0.0.1", port=9000)
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No webhook calls at all | Endpoint not saved, empty URL, or the receiver isn't a member of the workspace with the URL configured. | Set the Webhook Endpoint in Workspace Settings and save. Webhooks fire only for transfers received by workspace members, on the receiver's machine. |
| Approval unavailable after ~10s | The receiver's machine can't reach your endpoint: DNS, firewall, server down, or slow TLS handshake. | Curl the URL from the receiver's machine. Allow inbound traffic to the endpoint. Keep the connection path fast, the budget is 10 seconds to connect. |
| Transfers blocked though you return accept | Response isn't a clean 2xx with a parseable body: non-2xx status, empty body, invalid JSON, or a proxy rewriting the response. | Send the example payload with curl and inspect your raw response. Return {"status": "accept"} with HTTP 200. |
| Rejected with no explanation shown | You returned {"status": "reject"} without an error field (or an empty string). |
Include the error field with Markdown. |
| Markdown cut off or not rendering | The explanation exceeds the size limit, the generated list and explanation cannot fit the encoded peer message, or the Markdown syntax is invalid. | Keep explanations concise so the generated item list and Markdown fit the peer message together. Use standard Markdown; an oversized pre response blocks approval. |
| Config changes not picked up | The desktop app refreshes workspace settings periodically. | Wait for the next refresh, or restart the app to pick up the change immediately. |
| Local testing doesn't work | 127.0.0.1 only resolves on the machine itself, but the webhook runs on the receiver's machine. |
Run the example receiver on the machine that receives files, or expose your server with Cloudflare Tunnel or ngrok and configure its public HTTPS URL. |
| Pre-transfer hook is slow and holds up transfers | The transfer waits for your verdict by design. | There is no processing timeout, but keep the pre-transfer hook as fast as your checks allow. Move slow work to the post-transfer hook. |
If you run into any other problems, email us at [email protected]. For faster diagnostics, open Settings → See Debug Logs in the desktop app and attach the most recent log file to your message.
Security notes
- No authentication headers are sent. Anyone who can reach your URL can receive transfer metadata. Protect the endpoint yourself: a token in the URL path, an IP allowlist, or your own auth scheme.
- The payload is metadata only (emails, file names, sizes, paths, timestamps). File contents are never sent.
- Validate the payload like any external input, especially
relative_pathif you build filesystem paths from it.