Docs / Contact / messages/file-transfer

File Transfer

File transfer lets an entity send a file over HTTP: to another entity, to every member of a fanout, to a tag-routed automation, or to an entity in another account. Every file is scanned for malware by Surface before it becomes downloadable, and each transfer consumes one Surface scan, charged to the sender. Files are ephemeral: deleted once consumed, or when they age past your plan's retention window if never collected.

Secure by default

Unlike a plain upload API, file transfer enforces malware scanning that most products don't have by default. A file is only ever delivered to a recipient after a clean scan. Malicious files are rejected and never stored.

How it works

  1. The sender uploads a file with a recipient (dest) or routing tags.
  2. Contact validates it (type allowlist, size, the sender's data limit, and that the
  3. destination is deliverable) before spending a scan.

  4. The file is scanned by Surface, billed to the sender. Only non-malicious files are
  5. stored.

  6. The recipient(s) list and download it. A successful download consumes the
  7. recipient's copy; storage is reclaimed once every recipient has collected it (or by the retention sweep).

A receiver only ever sees files that passed scanning and are addressed to them; there is no "pending" or "quarantined" state visible to the recipient. The sender always pays the scan and bears storage until the file is consumed; no recipient is ever charged scans or storage for receiving a file.

Upload a file

code

POST https://app.tendrl.com/api/entities/files
Authorization: Bearer YOUR_ENTITY_API_KEY
Content-Type: multipart/form-data
Field Type Required Description
file file Yes The file to upload (multipart field name file).
dest string One of dest/tags Recipient: an entity, a fanout, or a full account:region:entity:name resource path for cross-account.
tags string One of dest/tags Comma-separated routing tags. Hands the file to matching Strand automations instead of a named recipient.
kind string No clip marks a captured motion clip (persists for review). Defaults to a normal file.
meta string No A JSON object of custom context (≤8KB) stored with the file, e.g. {"zone":"driveway","trigger":"PIR"}. Returned as metadata in the file's API record.

Delivery modes

The mode is inferred from dest / tags. In every mode the sender is the only party billed: the scan and the at-rest storage are charged to the sender, never to a recipient.

Mode How to address it Behavior
Direct dest = an entity in your account One recipient. Deleted after they download it.
Fanout / broadcast dest = a fanout in your account Delivered to every member. Each member downloads independently; the object is kept until the last member collects it (or TTL).
Tag-routed → automation tags = routing tags (no dest) Matching Strand workflows are triggered and fetch the bytes over an internal channel. Consumed once fetched.
Cross-account dest = account:region:entity:name in another account Delivered to an entity in a different account. The recipient must opt in (see Cross-account transfer).
bash

curl -X POST https://app.tendrl.com/api/entities/files \
  -H "Authorization: Bearer YOUR_ENTITY_API_KEY" \
  -F "[email protected]" \
  -F "dest=gateway-01"

A clean upload returns 201:

json

{
  "transfer_id": "7f3c…",
  "status": "clean",
  "mode": "direct",
  "file_name": "reading.csv",
  "size": 20480,
  "sha256": "…",
  "threat_level": "Clean"
}

A tag-routed upload returns "status": "awaiting_fetch" and "mode": "tag" (the file is held until a Strand automation fetches it). A broadcast returns "mode": "group" with delivered and skipped counts.

Receive files

List clean files addressed to your entity (the inbox):

bash

curl https://app.tendrl.com/api/entities/files \
  -H "Authorization: Bearer YOUR_ENTITY_API_KEY"
json

{ "files": [ { "transfer_id": "7f3c…", "file_name": "reading.csv", "size": 20480, "status": "clean" } ] }

Download a file by its transfer_id:

bash

curl -OJ https://app.tendrl.com/api/entities/files/download/7f3c… \
  -H "Authorization: Bearer YOUR_ENTITY_API_KEY"
Download consumes the file

By default a file is deleted after the recipient downloads it. Download once and persist it on your side. A second download returns 410 Gone.

Sending files from a Strand workflow

A Strand automation can be a sender, not just a recipient. The Contact: Send File node (platform.contact.send_file) posts to the same POST /api/entities/files endpoint with an entity API key, so everything on this page applies: mandatory scanning, the delivery modes above, and the sender-pays billing rule.

This closes the loop with tag-routed delivery: a file uploaded with tags triggers a Strand workflow, which can fetch the bytes, process them, and send a new file back out to a device (dest), a fanout, another account, or onward to more tags.

The node takes the file bytes base64-encoded (it chains directly from a Contact: Fetch File node or an HTTP download), plus the same routing fields used above (dest, tags, or kind: clip). The sending entity needs the entity:SendFiles permission. See the Contact Entity connector for node configuration and examples.

Motion clips (cameras)

Beyond the live JPEG stream, an OpenMV camera can capture a short video clip and send it for review later. For example, when a motion sensor fires, record a few seconds of JPEG frames and upload them as a zip. A clip is an ordinary file with kind=clip, so it's malware-scanned like everything else, but it persists for review (it is not consumed on first download) until its retention window elapses.

python

# MicroPython / OpenMV: capture and send a clip when the scene changes
from tendrl import Client
from tendrl.vision import Camera, Watch, motion

client = Client(mode="sync")
client.start()
time.sleep(5)

cam = Camera()

def on_change(state, prev, img):
    if state != "motion":
        return
    client.send_clip_burst(
        cam.snapshot,
        duration_s=5,
        fps=8,
        tags=["motion-alert"],
        meta={"zone": "driveway"},
    )

watch = Watch(client, detect=motion(threshold=0.02), debounce=2,
              cooldown_s=30, tags=["motion-alert"], on_change=on_change)

while True:
    watch.update(cam.snapshot())

Watch debounces the detector so a shadow or a single bad exposure doesn't cost you a clip (and one scan against your quota). See Vision.

Under the hood this records JPEG frames, packs them into a zip with meta.format=jpeg_burst, and uploads as kind=clip. Contact transcodes the burst server-side into an MP4 (preview.mp4) and poster image for browser playback with play, pause, and seek controls.

To send a pre-made clip from any SDK, call the normal upload with the kind=clip field and optional meta JSON:

bash

curl -X POST https://app.tendrl.com/api/entities/files \
  -H "Authorization: Bearer YOUR_ENTITY_API_KEY" \
  -F "file=@person_clip.zip" -F "kind=clip" \
  -F 'meta={"format":"jpeg_burst","fps":8,"frame_count":40}'

Reviewers list clips with ?kind=clip. Playback uses the transcoded video when available:

code

GET /api/files?kind=clip
GET /api/files/{transfer_id}/video    # MP4 preview (jpeg_burst clips)
GET /api/files/{transfer_id}/poster   # poster thumbnail
GET /api/files/{transfer_id}/raw      # original zip (or legacy GIF)

send_clip() (animated GIF) is deprecated but still supported for older firmware. Keep resolution and fps modest, since clip size is bounded by the per-file scan limit.

Cross-account transfer

A file can be sent to an entity in another account by addressing the full resource path (account:region:entity:name). Because this crosses a billing boundary, it is guarded on both sides:

bash

# Recipient opts in and allowlists sender account 880001 (account-admin token)
curl -X PUT https://app.tendrl.com/api/entities/files/settings \
  -H "Authorization: Bearer ACCOUNT_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cross_account_files_enabled": true, "cross_account_sender_allowlist": [880001]}'

# Sender uploads to the recipient's full resource path
curl -X POST https://app.tendrl.com/api/entities/files \
  -H "Authorization: Bearer SENDER_ENTITY_API_KEY" \
  -F "[email protected]" \
  -F "dest=990002:us-1:entity:partner-gw"

# Recipient re-scans a received file with their own profile (billed to recipient)
curl -X POST https://app.tendrl.com/api/entities/files/TRANSFER_ID/rescan \
  -H "Authorization: Bearer RECIPIENT_ENTITY_API_KEY"

SDK examples

python

# Python
result = client.send_file("reading.csv", dest="gateway-01")       # direct
print(result["transfer_id"], result["status"])                    # … clean

client.send_file("firmware.bin", dest="fleet-sensors")            # fanout broadcast
client.send_file("reading.csv", tags=["telemetry", "ingest"])     # tag-routed → automation
client.send_file("report.pdf", dest="990002:us-1:entity:partner-gw")  # cross-account

for f in client.check_files():
    data = client.download_file(f["transfer_id"])

# Recipient-side optional re-scan of a received cross-account file (billed to recipient)
client.rescan_file(transfer_id)
go

// Go: pass dest OR tags to SendFile(path, dest, tags)
res, _ := client.SendFile("reading.csv", "gateway-01", nil)                  // direct
client.SendFile("firmware.bin", "fleet-sensors", nil)                        // fanout
client.SendFile("reading.csv", "", []string{"telemetry", "ingest"})          // tag-routed
client.SendFile("report.pdf", "990002:us-1:entity:partner-gw", nil)          // cross-account
files, _ := client.CheckFiles(50)
data, _ := client.DownloadFile(files[0]["transfer_id"].(string))
client.RescanFile(transferID)                                                // recipient re-scan
javascript

// JavaScript: pass dest OR tags
await client.sendFile(file, { dest: "gateway-01" });                         // direct
await client.sendFile(file, { dest: "fleet-sensors" });                      // fanout
await client.sendFile(file, { tags: ["telemetry", "ingest"] });             // tag-routed
await client.sendFile(file, { dest: "990002:us-1:entity:partner-gw" });      // cross-account
const files = await client.checkFiles();
const bytes = await client.downloadFile(files[0].transfer_id);
await client.rescanFile(transferId);                                         // recipient re-scan
python

# MicroPython
client.send_file("/data/reading.csv", dest="gateway-01")
for f in client.check_files():
    data = client.download_file(f["transfer_id"])

Limits & policy

Aspect Behavior
Scanning Mandatory. Every file is scanned by Surface; one scan per transfer.
Scan quota When the monthly Surface scan quota is exhausted, file transfer pauses (HTTP 402) until the quota resets or the plan is upgraded.
Verdict Malicious files are blocked (422). Suspicious files are allowed but recorded.
File types At minimum the Surface "Default" allowlist (documents, images, archives, etc.). Raw executables and scripts are blocked.
Size Capped per plan (and by the scan ceiling). Oversize uploads return 413.
Retention Deleted on consume (the last recipient, for a broadcast), or after the retention window if never collected.
Data limit Each upload counts against the sender's monthly data limit for the billing period (not refunded on download, delete, or TTL purge), including cross-account transfers, which are billed to the sender.

Errors

Status Meaning
400 Missing recipient/tags, unknown recipient, or a group with no deliverable members.
402 Surface monthly scan quota exhausted; transfer paused.
403 File transfer disabled, or the recipient is not accepting cross-account files from your account.
413 File too large, or the sender's account data limit reached.
415 File type not allowed.
422 File blocked (malicious).
409 / 410 File still scanning / already consumed.

Next steps