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.
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
- The sender uploads a file with a recipient (
dest) or routingtags. - Contact validates it (type allowlist, size, the sender's data limit, and that the
- The file is scanned by Surface, billed to the sender. Only non-malicious files are
- The recipient(s) list and download it. A successful download consumes the
destination is deliverable) before spending a scan.
stored.
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
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). |
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:
{
"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):
curl https://app.tendrl.com/api/entities/files \
-H "Authorization: Bearer YOUR_ENTITY_API_KEY"
{ "files": [ { "transfer_id": "7f3c…", "file_name": "reading.csv", "size": 20480, "status": "clean" } ] }
Download a file by its transfer_id:
curl -OJ https://app.tendrl.com/api/entities/files/download/7f3c… \
-H "Authorization: Bearer YOUR_ENTITY_API_KEY"
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.
# 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:
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:
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:
- The recipient must opt in. Cross-account receiving is off by default. The
- The sender pays the mandatory scan and bears storage until the recipient
- The recipient may optionally re-scan the file with their own Surface profile,
receiving account turns it on and allowlists the specific sender accounts it will accept files from. An upload to an account that hasn't opted in (or hasn't allowlisted the sender) is rejected with 403 before the sender spends a credit.
downloads, so a sender can never drain a recipient's scan quota or fill their storage.
charged to the recipient's credits. The recipient is shown the sender's verdict (threat_level + scan time), never the sender's scan-profile internals. A stricter recipient verdict blocks the file on the recipient's side.
# 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
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: 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: 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
# 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. |
Tendrl