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.

Configuration

  1. Sign in to the Team Admin Dashboard (Team plan required).
  2. Go to Workspace Settings → Webhook Endpoint.
  3. Enter the full URL of your endpoint, e.g. https://example.com/hooks/zettasend.
  4. 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."
}

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

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_path if you build filesystem paths from it.