Transfer API Reference¶
The Transfer API sends transfers from your Leapfile account over HTTPS. The base URL is your own site plus /v1 — for example https://your-company.leapfile.com/v1.
Before you start, read Leapfile APIs for how the feature is turned on, how to create a key, and how authentication, rate limiting and error responses work. For a worked integration — retries, cleanup, and reference code — see Sending files programmatically.
Endpoints¶
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/transfers |
Create a draft |
GET |
/v1/transfers |
List sent transfers |
GET |
/v1/transfers/{transfer_id} |
Retrieve one transfer |
POST |
/v1/transfers/{transfer_id}/files |
Attach a file to a draft |
GET |
/v1/transfers/{transfer_id}/files |
List a transfer's files |
GET |
/v1/transfers/{transfer_id}/files/{file_id} |
Retrieve one file record |
DELETE |
/v1/transfers/{transfer_id}/files/{file_id} |
Remove a file from a draft |
DELETE |
/v1/transfers/{transfer_id} |
Discard a draft |
POST |
/v1/transfers/{transfer_id}/send |
Send the draft |
A transfer is created as a draft, filled, then sent. Nothing is delivered, notified or billed until the send call. A draft's subject, message and recipients are fixed at creation: to change them, discard the draft and create another.
Objects¶
Transfer
| Field | Type | Notes |
|---|---|---|
id |
string | The transfer id. Save it as soon as you create a draft. |
status |
string | See Transfer status. |
subject |
string | |
message |
string or null | |
recipients |
array | See below. |
files |
array | Present when you retrieve one transfer; absent from the list response. |
expires_at |
string | ISO 8601. On a draft, 30 days from creation. On a sent transfer, the account's delivery window counted from the send. |
sent_at |
string or null | null on a draft. |
Recipient
| Field | Type | Notes |
|---|---|---|
email |
string | |
name |
string | Falls back to the part of the email address before the @ when you don't supply one. This is what the pickup email greets them by. |
picked_up_at |
string or null | null until that recipient downloads the transfer. |
A recipient's phone is accepted when you create a transfer but is never returned.
File
| Field | Type |
|---|---|
id |
string |
name |
string |
size |
integer (bytes) |
Transfer status¶
| Status | Meaning |
|---|---|
draft |
Created but not sent. Not delivered, not billed, invisible to recipients. |
pending |
Sent; nobody has collected it yet. |
partial |
Some recipients have collected it. |
complete |
Every recipient has collected it. |
canceled |
Canceled in the web application. |
expired |
Past expires_at without being fully collected. |
unknown |
An old record whose stored status predates this vocabulary. Only from GET /v1/transfers/{transfer_id}, never from the listing, and not a value you can filter on. Handle it rather than assuming the list above is closed. |
expired is both a stored status and one worked out on read: a pending or partial transfer past its expires_at reports expired on the next read with no action on your part. complete, canceled and draft are never overridden this way.
Create a transfer¶
POST /v1/transfers
Content-Type: application/json
| Field | Required | Notes |
|---|---|---|
subject |
yes | Non-empty string. |
recipients |
yes | Non-empty array of objects with email, and optionally name and phone. Up to 250. Trial and free subscriptions are limited to one recipient. Duplicate addresses are not merged; each one counts. |
message |
no | Up to 2,000 characters. |
security |
no | See Recipient authentication. Omit to use the account's default. |
security_question |
only with security: "question" |
|
security_answer |
only with security: "question" |
{
"subject": "Q3 audit files",
"message": "The signed statements are attached.",
"recipients": [{"email": "auditor@example.com", "name": "Dana Reyes"}]
}
Returns 201 with the transfer object, and a Location header holding the new transfer's path — a path such as /v1/transfers/{transfer_id}, not an absolute URL.
{
"id": "8f14e45fceea167a",
"status": "draft",
"subject": "Q3 audit files",
"message": "The signed statements are attached.",
"recipients": [
{"email": "auditor@example.com", "name": "Dana Reyes", "picked_up_at": null}
],
"files": [],
"expires_at": "2026-09-20T14:02:11Z",
"sent_at": null
}
Treat id as an opaque string; its length and format are not part of the contract.
Recipient addresses are checked for form here, so a malformed address fails at creation rather than at send. The recipient count is checked at send instead, so on a subscription limited to one recipient a five-recipient draft is created and then refused when you send it.
subject is only required to be a non-empty string; the API applies no length limit to it. Keep it to the length of an email subject.
List transfers¶
GET /v1/transfers
| Parameter | Default | Notes |
|---|---|---|
limit |
25 |
Clamped into 1–100 rather than rejected, so limit=1000 returns 100. A value that is not a whole number is a 400. |
status |
all |
all, pending, partial, complete, canceled or expired. |
cursor |
— | The next_cursor from the previous page. |
{
"data": [ ... ],
"next_cursor": "MjAyNi0wOC0yMVQxMjowMDowMFo6MjU"
}
Newest first. next_cursor is null on the last page.
Drafts are not in the listing
This endpoint returns sent transfers only, and status=draft is rejected. A draft is reachable only by its id, so persist the id the moment you create one.
The cursor is based on send time. A transfer sent while you're paging shows up on a later page, not one you've already fetched.
A status filter can make you skip a transfer
Each page re-checks the status filter. If a transfer stops matching after you've paged past where it would have been — say a pending one gets picked up — it disappears from the results, and everything below it moves up one spot. That row now lands on a page you already read, so you miss it.
To see every transfer with no gaps, page with status=all and filter in your own code instead. A transfer only vanishes from that unfiltered list if it's canceled or deleted while you're paging.
Retrieve a transfer¶
GET /v1/transfers/{transfer_id}
Returns 200 with the transfer object, including files and each recipient's picked_up_at. Works for drafts and for sent transfers.
Attach a file¶
POST /v1/transfers/{transfer_id}/files
Content-Length: 184320
Content-Disposition: attachment; filename="statements.pdf"
The request body is the raw bytes of the file — one call per file, streamed straight through to storage, so the size of the file never has to fit in memory. It is not a multipart upload.
Content-Lengthis required. Without it the request fails with411. Some HTTP clients switch to chunked encoding for a streamed body and drop the header; set it explicitly from the file size. Take it from the actual size of the file: the API stores exactly the number of bytes you declare, so aContent-Lengthsmaller than the file stores a truncated file and reports success.- The filename comes only from
Content-Disposition. Any directory part is stripped.filename*(RFC 5987) is used in preference tofilenamewhen both are present. - The request's
Content-Typeis ignored. The stored type is worked out from the file extension, so give the file a correct one. - The size limit is your account's per-file limit, checked against
Content-Lengthbefore any bytes are read. The file's size is rounded up to whole binary megabytes (1 MB = 1,048,576 bytes) for the comparison, so a file one byte over a whole megabyte counts as the next one up, and a 100.1 MB file is refused against a 100 MB limit. The413message reports that rounded figure, not the exact byte count.
Returns 201 with the file object, and a Location header holding the new file's path, when the file is attached.
Returns 200 with the existing file object when that filename is already attached to the draft. Nothing is stored twice and nothing is billed twice, so a timed-out upload is safe to repeat.
Give every file on a transfer a distinct name
The filename identifies the file within the transfer, and directory parts are stripped before it is used — 2026-08-21/report.csv and 2026-08-20/report.csv are both report.csv here, so they do not arrive as two files.
A job that collects files from a directory tree should build a distinct name for each one, folding in the date or the folder it came from.
An upload that is cut off partway attaches nothing: the partial bytes are discarded and the call fails with 500. There is no half-attached state to clean up — repeat the call and it sends the file again from the start.
Files can only be attached to a draft. Attaching to a sent transfer returns 409 invalid_state.
List files, retrieve a file¶
GET /v1/transfers/{transfer_id}/files
GET /v1/transfers/{transfer_id}/files/{file_id}
The list returns {"data": [ ... ]} with every file on the transfer in one response. Both work on sent transfers as well as drafts.
Remove a file¶
DELETE /v1/transfers/{transfer_id}/files/{file_id}
Returns 204 with an empty body. Draft only; on a sent transfer this returns 409 invalid_state.
Discard a draft¶
DELETE /v1/transfers/{transfer_id}
Returns 204 with an empty body.
409 invalid_state means the transfer has already been sent; cancel a sent transfer in the web application. 404 means the draft was already discarded.
Send¶
POST /v1/transfers/{transfer_id}/send
No request body. This is the call that bills the transfer, emails the recipients and starts the expiration clock.
Returns 200 with the sent transfer. status is pending and expires_at has been recalculated from the account's delivery window.
A draft can be sent once. A second call returns 409 invalid_state.
409 invalid_state on a send has two causes that share one code: the transfer is no longer a draft, and the draft has no files attached. Only the message separates them, so read the transfer back before recording a send as done — see Retries and sending exactly once.
The send also fails, with its own codes, if the draft would exceed the subscription's recipient limit (409 recipient_limit_exceeded) or the number of transfers the account allows to be outstanding at one time (409 transfer_limit_reached).
Every send masks the recipients from one another, includes your message in the notification, and emails each recipient. These follow the account and the sending user's own defaults rather than the request.
Recipient authentication¶
security decides what a recipient must do before downloading. Omit it and the account's configured default applies, which keeps API transfers consistent with what the same account sends from the web.
| Value | The recipient must | Also required |
|---|---|---|
none |
Nothing; the link is enough | — |
email |
Confirm their email address | — |
email_code |
Enter a code emailed to them | — |
question |
Answer a question you set | security_question and security_answer |
sms |
Enter a code texted to them | phone on every recipient |
login |
Register as a guest and sign in | — |
Two rules to build against:
- An option the account does not have is a
403 auth_option_not_enabled. A transfer never goes out with less protection than you asked for. - Fields an option does not use are rejected. Sending
security_answerwithsecurity: "none"is a400.
For sms, the phone number must be digits once spaces, brackets, dashes and other punctuation are removed.
{
"subject": "Contract",
"recipients": [{"email": "legal@example.com", "phone": "+15125550123"}],
"security": "sms"
}
Error codes¶
Every error returns {"error": "...", "message": "..."}. Branch on error; the wording of message can change.
| Status | Code | Meaning |
|---|---|---|
400 |
invalid_request |
The body or a parameter is wrong. message names the field. |
401 |
invalid_api_key |
Missing, malformed, unknown or revoked key, or the owning user is deactivated. |
403 |
api_not_enabled |
The Transfer API account feature is off, or the plan does not include outgoing transfers. An administrator fixes this. |
403 |
api_not_permitted |
The user does not have Use the transfer API. An administrator fixes this. |
403 |
sending_not_permitted |
The user is not allowed to send files at all. |
403 |
auth_option_not_enabled |
The account does not have the security option you asked for. |
403 |
no_credit |
The account bills per transfer and has no credit left. |
404 |
not_found |
Unknown id, discarded draft, or a transfer sent by a different user. |
409 |
invalid_state |
The transfer is not in a state that allows this: already sent, or a send with no files attached. The message says which. |
409 |
transfer_limit_reached |
The account already has the maximum number of transfers outstanding. |
409 |
recipient_limit_exceeded |
More recipients than the subscription allows. |
411 |
length_required |
The upload has no usable Content-Length. |
413 |
file_too_large |
The file is over the account's per-file limit. message gives the rounded size and the limit. |
429 |
rate_limited |
60 requests in the last 60 seconds on this key. Wait Retry-After seconds. |
500 |
internal_error |
A fault on our side. Retry with backoff; contact support with the time of the request if it persists. |
502 503 504 |
service_unavailable |
A dependency is down or slow. Retry with backoff. |
404 also covers "not yours", so it does not distinguish a mistyped id from a colleague's transfer.
Limits¶
Several of these are account settings. Ask an administrator for your account's values.
| Limit | Value |
|---|---|
| Requests | 60 per 60 seconds, per key |
| Recipients per transfer | 250; 1 on trial and free subscriptions |
| Message | 2,000 characters |
| File size | Your plan's per-file limit |
| Files per transfer | Unlimited, in count and in total size. Each file needs a unique name within the transfer |
| Draft lifetime | 30 days from creation |
| Sent transfer lifetime | Your account's delivery window, counted from the send |
| Outstanding transfers | Your account's limit, checked at send. The count covers every sent transfer not yet collected, canceled or expired, and a scheduled send waiting in the web application or desktop client. Drafts do not count, so an abandoned draft costs you nothing but the clutter |