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, and once after it completes, 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.
- Webhooks always run on the receiver's machine. The receiver's workspace settings decide whether a transfer is gated.
- Two calls per transfer: a pre-transfer call before it starts and a post-transfer call after it completes.
- The pre-transfer hook is a hard gate: unless it approves the transfer, the transfer does not happen.
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.
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 let the transfer start or reject to block it. This is the gate: no approval, no transfer. |
| Post-transfer webhook_type: "post" |
After all 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. |
Error messages are always shown on both ends, for both hook types. The sender sees the markdown explanation in their app when a transfer is rejected, and the receiver sees it on their side too.
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 daily delivery from a production house to a VFX studio: two DPX sequences plus their reference movs, nested inside a per-episode folder.
{
"webhook_type": "pre",
"sender_email": "[email protected]",
"receiver_email": "[email protected]",
"file_count": 4,
"total_bytes": 2182348800,
"occurred_at_utc": "2026-08-31T22:04:45.6421905+00:00",
"destination_path": "D:\\VFX\\Incoming",
"manifest": {
"files": [
{
"relative_path": "ep101\\sc010_010_v001.dpx",
"length": 892715008
},
{
"relative_path": "ep101\\sc010_020_v001.dpx",
"length": 892715008
},
{
"relative_path": "ep101\\sc010_010_v001.mov",
"length": 298844160
},
{
"relative_path": "ep101\\plates\\plate_101_10.mov",
"length": 98074624
}
]
}
}
Fields
| Field | Type | Description |
|---|---|---|
| webhook_type | string | "pre" before the transfer starts, "post" after it completes. |
| 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. |
| 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 reject 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 with explanation
{
"status": "reject",
"error": "## Naming convention violation\n\nThe following files do not match the\n`sc###_###_v###` convention:\n\n- `ep101\\sc010_010_v001.dpx`"
}
Rejecting without an error field (or with an empty one) produces a generic
rejection with no explanation shown.
How the app interprets your response
| Your response | Result |
|---|---|
| 200 + {"status": "accept"} | Approved. Pre-transfer: the transfer proceeds. Post-transfer: success. |
| 200 + {"status": "reject"} | Rejected without explanation. |
| 200 + {"status": "reject", "error": "..."} | Rejected; your markdown is rendered in the app on both ends. |
| 2xx with empty body | Treated as accept. |
| 2xx with non-JSON body | Delivery failure, no verdict can be read. |
| Non-2xx (4xx / 5xx) | Delivery failure. |
| Reject with error > 65,536 bytes | Markdown is capped at 65,536 bytes; larger errors won't render fully. |
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. Fail-closed by design, since no approval was given.
- Post-transfer: the transfer already completed; an error is surfaced to both ends.
Example receiver
A Flask receiver with example post-production logic. The pre-transfer hook enforces the studio's shot naming convention and rejects anything that doesn't match. The post-transfer hook logs the delivery and is the place to kick off downstream automation. Any language or framework works, it's just a JSON POST.
import re
from pathlib import Path
from flask import Flask, request, jsonify
app = Flask(__name__)
# Studio convention: 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}\.")
@app.route("/zettasend/transfers", methods=["POST"])
def transfers():
payload = request.get_json()
dest = Path(payload["destination_path"])
files = payload["manifest"]["files"]
if payload["webhook_type"] == "pre":
# Sanity checks run before the transfer may start
bad = [f["relative_path"] for f in files if not SHOT_PATTERN.match(f["relative_path"])]
if bad:
names = "\n".join("- `%s`" % f for f in bad)
return jsonify({
"status": "reject",
"error": "## Naming convention violation\n\n"
"These files do not match `sc###_###_v###`:\n\n%s\n\n"
"Re-export with correct shot names and resend." % names
}), 200
return jsonify({"status": "accept"}), 200
# Post-transfer: all files are verified on disk, delivery is complete
delivered = [dest / f["relative_path"] for f in files]
print("[delivery] %d files written under %s" % (len(delivered), dest))
# Trigger downstream work here, e.g. register the delivery in your asset DB
return jsonify({"status": "accept"}), 200
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. |
| "Connection timed out" 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 | Error exceeds the 65,536-byte limit, or invalid Markdown syntax. | Keep explanations under 65,536 bytes. Use standard Markdown. |
| 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 via a tunnel (ngrok, cloudflared). |
| 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.