The simplest reliable way to send files from Python is to let a purpose-built uploader move the bytes and use Python for everything around it: call the Yungle CLI with --json to upload and send, parse the link it returns, then query the API with requests to see who downloaded it. You get resumable uploads without writing a resumable uploader, and your script stays about twenty lines long.
I built Yungle, so this is a guide to our API specifically. The shape — let a proper uploader do the bytes, keep the orchestration in Python — applies to any service.
Why not just requests.post the file
It is tempting to post the file in one request. For a small file, it works. For a large one, it fails in the most annoying way: a dropped connection at 90% throws everything away and starts again, and at a few gigabytes something always drops.
Large uploads need to be resumable — sent in parts, with the ability to pick up from the last part the server confirmed. Yungle uses tus, an open protocol for exactly this. You can speak tus from Python, but you do not have to: the CLI already does it, correctly, and handles the tokens. Why resumable uploads are hard explains what it saves you from.
Set up once
Install the CLI and give it an API key from your account settings:
npm install -g yungle-cli
export YUNGLE_API_KEY=yk_live_...
yungle auth statusThe CLI reads YUNGLE_API_KEY from the environment first, which is what you want on a server or in a scheduled job. Give the key only the scopes the script needs — for sending and checking receipts, transfers:write (which includes read of transfers) is enough.
Send a file and get the link back
import json
import subprocess
def send(paths, to, message=None):
cmd = ["yungle", "send", *paths, "--json"]
for address in to:
cmd += ["--to", address]
if message:
cmd += ["--message", message]
# Progress goes to stderr; stdout is a single JSON object.
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(result.stdout)
sent = send(["exports/report-2026-09.pdf"], to=["client@example.com"],
message="September report attached.")
print(sent["url"], sent["expiresAt"], sent["notified"])--json makes the CLI print one JSON object with the transfer's id, url, expiresAt and the addresses that were emailed. Progress and diagnostics go to stderr, so they never land in what you parse.
A few flags worth knowing: --password puts a password on the link, --expires <days> sets its lifetime (clamped to what your plan allows), and --title labels it in your dashboard without showing it to the recipient.
Check who downloaded it
This is where plain requests is the right tool. The API reports downloads per recipient:
import os
import requests
API = "https://yungle.co/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['YUNGLE_API_KEY']}"}
def receipts(transfer_id):
r = requests.get(f"{API}/transfers/{transfer_id}/downloads", headers=HEADERS, timeout=30)
r.raise_for_status()
data = r.json()
return {rec["email"]: rec["downloaded"] for rec in data["recipients"]}
print(receipts(sent["id"])) # {'client@example.com': False}There are no webhooks yet, so if you need to act on a download, poll — gently. Once an hour is plenty for a human recipient.
Doing it without the CLI
If you cannot install Node on the machine, you can call the API directly: POST /transfers with each file's name and exact size in bytes returns a tus endpoint and an upload token per file; you upload each file with a tus client, sending the token in the x-yungle-upload-token header on every request; then POST /transfers/{id}/finalize with the recipients makes the link live and sends the email. The uploads guide lists the rules that bite — exact sizes, two-hour token windows, and never re-posting an upload that is already in progress.
Things to decide before you schedule it
Metering. API uploads include 10 GB a month on every plan, including Free, and are charged per GB from prepaid credit beyond that. Downloads are never metered. A nightly job sending a 1 GB export uses a third of that in ten days — do the sum first.
Expiry. A script that runs daily produces a lot of links. Give them a sensible lifetime with --expires, rather than accumulating every report forever.
Failure. check=True makes a failed send raise, which is what you want: a scheduled job that swallows an upload error produces a green run and no delivery.
What the API cannot do
Worth knowing before you design around it: the vault and end-to-end encrypted transfers are not reachable from the API or the CLI, because their keys are created in the browser and never sent to Yungle. What the API cannot do has the full list.
If sending is a step in an automated workflow, the page for automation and CI shows the pattern end to end. If your files come out of a build rather than a script, sending large files from CI covers the pipeline version, and the API guide covers the request sequence in more depth.