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.

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.

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

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