Sending files programmatically¶
This guide covers sending files from a system rather than a browser: a nightly export, a backup job, a document management system. For the exact shape of every endpoint, see the Transfer API Reference. For keys and permissions, see Leapfile APIs.
The model: a draft you fill, then send¶
Sending takes three calls.
Your system Transfer API
----------- ------------
POST /v1/transfers ------> Creates a draft
{subject, recipients} 201 -> id
Nothing delivered. Nobody notified.
|
POST /v1/transfers/{id}/files ------> Streams the bytes to storage
raw bytes, one call per file 201 -> file id
|
... repeat per file ...
|
POST /v1/transfers/{id}/send ------> Bills it, emails the recipients,
no body starts the expiration clock
200 -> the sent transfer
Files stream straight through to storage, so a 20 GB transfer is never held in memory or base64-encoded into a JSON body. Because the send is a separate step, a run that dies halfway leaves a draft: no recipient has been told anything, nothing has been billed, and you can finish it, discard it, or start again.
Treat the draft id as the unit of work. Save it as soon as you have it, before you upload anything. If your process restarts, that id tells you whether there is work to finish or to clean up.
Quick start¶
Create a key from My Profile → Integrations → API Keys (an administrator has to turn the feature on first — see Turning the Transfer API on), then:
KEY="lf_YOUR_API_KEY_HERE"
BASE="https://your-company.leapfile.com/v1"
# 1. Create the draft
ID=$(curl -s -X POST "$BASE/transfers" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "Q3 audit files",
"message": "The signed statements are attached.",
"recipients": [{"email": "auditor@example.com", "name": "Dana Reyes"}]
}' | jq -r .id)
# 2. Attach a file, once per file
curl -s -X POST "$BASE/transfers/$ID/files" \
-H "Authorization: Bearer $KEY" \
-H "Content-Disposition: attachment; filename=\"statements.pdf\"" \
-H "Content-Length: $(wc -c < statements.pdf)" \
--data-binary @statements.pdf
# 3. Send it
curl -s -X POST "$BASE/transfers/$ID/send" \
-H "Authorization: Bearer $KEY"
The last call returns the transfer with status: "pending". The recipients now have a pickup email.
Choosing a security option¶
security decides what a recipient has to do before downloading. Omit it to use the account default.
The values and their required companion fields are in Recipient authentication. Two rules matter when you set it explicitly: an option the account does not have returns 403, never a weaker option; and a field the chosen option does not use returns 400.
Retries and sending exactly once¶
The send is safe to retry. A draft can only be sent once. A second call finds a transfer that is no longer a draft and returns 409 invalid_state without sending anything. So when a send times out and you do not know whether it landed:
retry POST /v1/transfers/{id}/send
200 -> the first attempt never landed; this one did. Done.
409 invalid_state -> read the transfer back before deciding (see below).
invalid_state on a send has more than one cause
A send answers 409 invalid_state when the transfer is no longer a draft, and also when the draft has no files attached. The two differ only in the message. Do not record a send as complete on the status code alone.
Settle it with GET /v1/transfers/{id}: status other than draft means the earlier attempt landed and the transfer went out once, so you are done. status still draft means nothing was sent — attach the files and send again.
Other 409s on the send carry their own codes: transfer_limit_reached and recipient_limit_exceeded. Branch on error, never on the status alone.
Retry, do not parallelise
Send one call at a time, and start a retry only after the previous attempt has returned or timed out.
Two sends genuinely in flight at once both return 200, and the two responses are indistinguishable. The transfer goes out once and is billed once, but your client sees two successes for one transfer and nothing in either response says which call did the work.
Retrying an upload is safe as long as the name is the same. A name already attached to the draft returns the file that holds it: 200 instead of 201, no second copy, nothing billed twice.
retry POST /v1/transfers/{id}/files
201 -> the first attempt never landed; this one did.
200 -> the first attempt landed. The body is the file it created.
The name is what identifies the file, so a retry has to send the same name. A client that renames on retry, adding a timestamp or a (1) suffix, attaches a second copy instead. To check rather than retry, read the file list:
curl -s "$BASE/transfers/$ID/files" -H "Authorization: Bearer $KEY"
What to retry, by response:
| Response | Do |
|---|---|
409 invalid_state on send |
Read the transfer back. Not a draft = it already went, you are done. Still a draft = nothing was sent; fix what the message names and send again. |
200 on an upload |
Nothing. That name was already attached; the body is the file. |
409 transfer_limit_reached |
Wait and retry later. Do not create another draft. |
429 rate_limited |
Sleep for Retry-After seconds, then repeat the same call. |
500 internal_error |
Retry with backoff. |
502, 503, 504 service_unavailable |
Retry with backoff. A dependency of ours is down or slow. |
400, 403, 404, 411, 413 |
Do not retry. Repeating the call changes nothing; a 403 clears only when an administrator changes a setting. Fix it or alert. |
Discarding a draft¶
A draft that is never sent lives for 30 days. If your run fails after creating one, discard it:
curl -s -X DELETE "$BASE/transfers/$ID" -H "Authorization: Bearer $KEY"
204 means it is gone. 409 invalid_state means it was already sent, so the run succeeded and there is nothing to clean up. 404 means it was already discarded.
A draft is not in GET /v1/transfers; that listing covers sent transfers only. A draft is reachable by its id alone, so persist the id the moment you receive it — a create call that times out after the draft was made leaves one you can no longer reach, and it stays in the user's account until it expires 30 days later. It occupies none of the account's outstanding-transfer allowance while it sits there.
Knowing whether it was collected¶
picked_up_at on each recipient is null until that person downloads the transfer, and the transfer's own status rolls this up: pending (nobody yet), partial (some), complete (everyone).
curl -s "$BASE/transfers/$ID" -H "Authorization: Bearer $KEY" | jq '.status, .recipients'
For a handful of transfers, polling each id is fine. Once an hour is plenty, since pickup is a human action. For anything larger, poll the listing with a status filter instead of walking every id:
curl -s "$BASE/transfers?status=pending&limit=100" -H "Authorization: Bearer $KEY"
If what you want is a feed of pickup events rather than the current state of each transfer, Event Monitoring is built for that. It streams transfer_sent and file_downloaded events with a cursor, so you are not polling each transfer at all.
Reference implementation (Python)¶
This handles the two things a real run meets: a 429 partway through a batch, and a send whose
response is lost.
import os
import time
import requests
BASE = "https://your-company.leapfile.com/v1"
KEY = os.environ["LEAPFILE_API_KEY"]
AUTH = {"Authorization": f"Bearer {KEY}"}
ATTEMPTS = 6
def wait(response, attempt):
"""Sleep if the response is worth retrying, and report whether to retry.
Retry-After on a 429 is the seconds left in the rate limit window. Sleeping
it is the normal path for a batch of more than 58 files, not an error.
"""
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 60)))
return True
if response.status_code >= 500:
time.sleep(2 ** attempt)
return True
return False
def call(method, path, **kwargs):
"""One request with no body to replay, retried while it is 429 or 5xx."""
for attempt in range(ATTEMPTS):
response = requests.request(method, f"{BASE}{path}", headers=AUTH, **kwargs)
if not wait(response, attempt):
return response
raise RuntimeError(f"{method} {path} still failing after {ATTEMPTS} attempts")
def upload(transfer_id, path):
"""Stream one file into the draft. The body is the raw bytes.
The file is reopened on each attempt, because a retry has to send the body
again. Content-Length must be the real size of the file: the API stores
exactly the number of bytes you declare.
"""
headers = {
**AUTH,
"Content-Length": str(os.path.getsize(path)),
"Content-Disposition": f'attachment; filename="{os.path.basename(path)}"',
}
for attempt in range(ATTEMPTS):
with open(path, "rb") as handle:
response = requests.post(
f"{BASE}/transfers/{transfer_id}/files",
headers=headers,
data=handle,
timeout=None,
)
if not wait(response, attempt):
response.raise_for_status() # 200 means this name was already attached
return response.json()
raise RuntimeError(f"upload of {path} still failing after {ATTEMPTS} attempts")
def send(transfer_id):
"""Send the draft.
409 invalid_state means either that an earlier attempt already sent it or
that the draft has no files, so read the transfer back to tell which.
"""
response = call("POST", f"/transfers/{transfer_id}/send", timeout=60)
if response.status_code == 409 and response.json().get("error") == "invalid_state":
transfer = call("GET", f"/transfers/{transfer_id}", timeout=30).json()
if transfer["status"] != "draft":
return transfer # an earlier attempt sent it; done
raise RuntimeError(f"send refused: {response.json()['message']}")
response.raise_for_status()
return response.json()
def send_transfer(subject, recipients, paths, message=None, security=None):
"""Create a draft, attach every file, and send it.
A failure before the send discards the draft, so nothing is left behind and
nobody is notified. A failure during the send does not discard: the transfer
may have gone out, and a sent transfer is cancelled in the web application.
"""
body = {"subject": subject, "recipients": recipients}
if message:
body["message"] = message
if security:
body["security"] = security
response = call("POST", "/transfers", json=body, timeout=30)
response.raise_for_status()
transfer_id = response.json()["id"]
try:
for path in paths:
upload(transfer_id, path)
except Exception:
call("DELETE", f"/transfers/{transfer_id}", timeout=30)
raise
return send(transfer_id)
if __name__ == "__main__":
transfer = send_transfer(
subject="Q3 audit files",
recipients=[{"email": "auditor@example.com", "name": "Dana Reyes"}],
paths=["statements.pdf", "ledger.xlsx"],
message="The signed statements are attached.",
)
print(transfer["id"], transfer["status"], transfer["expires_at"])
Directory parts are stripped from a filename, so give every entry in paths a distinct base name — see Attach a file.
Reference implementation (Bash)¶
#!/bin/bash
set -euo pipefail
BASE="https://your-company.leapfile.com/v1"
KEY="${LEAPFILE_API_KEY:?set LEAPFILE_API_KEY}"
AUTH="Authorization: Bearer $KEY"
SUBJECT="$1"
RECIPIENT="$2"
shift 2 # everything left is a file path
ID=$(curl -sf -X POST "$BASE/transfers" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "$(jq -n --arg s "$SUBJECT" --arg e "$RECIPIENT" \
'{subject: $s, recipients: [{email: $e}]}')" | jq -r .id)
echo "draft $ID"
# Discard the draft if anything below fails. Nothing has been sent yet.
trap 'curl -s -X DELETE "$BASE/transfers/$ID" -H "$AUTH" >/dev/null' ERR
for FILE in "$@"; do
curl -sf -X POST "$BASE/transfers/$ID/files" \
-H "$AUTH" \
-H "Content-Length: $(wc -c < "$FILE")" \
-H "Content-Disposition: attachment; filename=\"$(basename "$FILE")\"" \
--data-binary "@$FILE" > /dev/null
echo "attached $(basename "$FILE")"
done
STATUS=$(curl -s -o /tmp/send.$$ -w '%{http_code}' \
-X POST "$BASE/transfers/$ID/send" -H "$AUTH")
trap - ERR
case "$STATUS" in
200) echo "sent $ID" ;;
409)
# invalid_state can mean "already sent" or "no files attached", and the
# other 409 codes mean nothing was sent. Read the transfer back to tell.
ERR_CODE=$(jq -r .error /tmp/send.$$)
SENT_STATUS=$(curl -sf "$BASE/transfers/$ID" -H "$AUTH" | jq -r .status)
if [ "$ERR_CODE" = "invalid_state" ] && [ "$SENT_STATUS" != "draft" ]; then
echo "already sent $ID"
else
echo "send refused ($(jq -r .error /tmp/send.$$)): $(jq -r .message /tmp/send.$$)"
exit 1
fi
;;
*) echo "send failed ($STATUS)"; cat /tmp/send.$$; exit 1 ;;
esac
rm -f /tmp/send.$$
This script keeps to the simple case: it does not handle 429, so use it for batches well under
58 files, or add the sleep the Python version shows.
Integration patterns¶
Create one transfer per run, attach the day's files, send. Record the returned id against the run so a failed retry can find and discard the previous draft instead of stacking up orphans.
Create the draft when the first file is ready and upload as files arrive, then send once the set is complete. Because nothing goes out until the send call, a half-assembled transfer is invisible to the recipients. The draft can sit unsent for up to 30 days.
The outstanding-transfer limit is what you meet first: transfers have to be collected or expire before more can go out, and a scheduled send waiting in the web application occupies a slot too. Handle 409 transfer_limit_reached by queueing and retrying later, never by creating more drafts — they fail at send for the same reason and leave drafts behind.
Troubleshooting¶
I get 401 but the key is correct¶
The header must be exactly Authorization: Bearer lf_.... A key that was revoked, or whose owning user was deactivated, also returns 401 with the same message. Confirm the key is still listed under My Profile → Integrations → API Keys.
I get 403 and I do not know which permission is missing¶
The code says which: api_not_enabled is the account feature or the plan, api_not_permitted is the per-user Use the transfer API grant, and sending_not_permitted means the user cannot send files at all. The first two are for an administrator; the last means the user is not set up for sending in the first place.
I get 404 on a transfer I can see in the web application¶
A key only sees transfers sent by its own user. A transfer sent by a colleague on the same account returns 404, the same as one that does not exist.
My upload fails with 411¶
Content-Length is required. Some HTTP clients switch to chunked encoding for streamed bodies and drop the header. Set it explicitly from the file size.
I get 429 in the middle of a transfer¶
The key has spent its allowance of 60 requests for the current 60-second window. It is counted across every endpoint, so a transfer with many files spends one request per file. The Retry-After header says how many seconds until the window reopens; sleep that long and repeat the call that was refused. Nothing is lost: the draft and its attached files are untouched and the send has not happened.
You do not have to track where you stopped. Replaying the whole file list is simpler and costs nothing, because the files that already landed return 200 and are not attached twice. Then send.
A draft of 59 files or more crosses a window however you pace it, so at that size the sleep is the normal path rather than an error to report. Rate limit has the arithmetic.
My upload fails with 413¶
The file is over the account's per-file limit. The message names the limit and the file's size rounded up to whole binary megabytes (1 MB = 1,048,576 bytes), so the figure it quotes is not the exact byte count. It is the same limit the web application applies, so raising it is an account change rather than an API one. Split the file or ask an administrator.
My upload fails with 409 invalid_state¶
The transfer has already been sent. Files can only be attached to a draft; start a new transfer.
The send returns 409 invalid_state and I never got a 200¶
Two possibilities, and the status code does not separate them. Read the transfer back with GET /v1/transfers/{id}:
statusis notdraft— an earlier attempt succeeded and its response was lost. The transfer went out once. Record it and move on.statusis stilldraft— nothing has been sent. The usual cause is a send with no files attached, and themessageon the409says so. Attach the files and send again.
Creating a transfer returns 500 internal_error¶
Retry with backoff. If it persists, contact support with the time of the request. A 500 is a fault on our side; nothing in your payload causes one.
My draft is not in GET /v1/transfers¶
That listing is sent transfers only. Retrieve a draft by its id, which is why the id is worth persisting the moment you receive it.
Sharing one key across several systems¶
This works, and there is nothing to coordinate: the API keeps no per-key position or session. Separate keys per system are still worth it: each has its own rate-limit allowance and its own last-used time, and one can be revoked without disturbing the others.