Send a transfer
Package Brault files, or files you upload, into one expiring download link.
A transfer packages files into a single public link that expires. It is the API behind
Brault's BTransfer: the recipient needs no account, downloads either the whole zip or
individual files, and the link stops working on its expiry date. Scopes needed:
transfers:write to create, update and expire, transfers:read to list and read back.
1. Send files that are already in Brault
With no uploads declared, one call does everything:
us.api.brault.app— follows the region selector in the top barcurl -X POST https://us.api.brault.app/v1/transfers \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-1111-4c1a-9c1e-transfer-create" \
-d '{
"files": [{ "id": "<file_id>" }, { "id": "<file_id>", "version_id": "<version_id>" }],
"folders": ["<folder_id>"],
"expires_in_days": 14,
"password": "clientaccess2026"
}'{
"object": "transfer",
"id": "aB3xY9zQ1p2",
"url": "https://acme.brault.app/d/aB3xY9zQ1p2",
"status": "processing",
"size": 184549376,
"file_count": 12,
"folder_count": 2,
"password_protected": true,
"expires_at": "2026-09-21T10:00:00.000Z",
"view_count": 0,
"download_count": 0,
"zip_url": null,
"created_at": "2026-09-07T10:00:00.000Z",
"updated_at": "2026-09-07T10:00:00.000Z",
"created_by_id": "<user_id>"
}url works immediately — recipients can browse and download individual files while the zip
is still building. A folder id brings its whole subtree. An id that does not exist or belongs
to another brandspace answers 404 not_found for the whole request; a file in this brandspace
that the key's creator cannot see is silently left out, and a request where nothing accessible
is left answers 400 transfer_no_accessible_content.
version_id is optional and picks an exact processed version of that file instead of the
active one. password needs the plan's transfer protection, and any expires_in_days other
than the default 7 needs custom expiry — without them the call answers
403 plan_feature_unavailable.
2. Send files you upload yourself
Declare them, and the transfer opens as a draft instead of finishing:
us.api.brault.app— follows the region selector in the top barcurl -X POST https://us.api.brault.app/v1/transfers \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-2222-4c1a-9c1e-transfer-draft" \
-d '{
"files": [{ "id": "<file_id>" }],
"uploads": [
{ "name": "brief.pdf", "size": 1048576 },
{ "name": "master.mov", "size": 3221225472 }
]
}'{
"object": "transfer",
"id": "kR7mN2pQ5vL",
"url": "https://acme.brault.app/d/kR7mN2pQ5vL",
"status": "awaiting_uploads",
"size": 0,
"file_count": 0,
"folder_count": 0,
"password_protected": false,
"expires_at": "2026-09-14T10:00:00.000Z",
"view_count": 0,
"download_count": 0,
"zip_url": null,
"created_at": "2026-09-07T10:00:00.000Z",
"updated_at": "2026-09-07T10:00:00.000Z",
"created_by_id": "<user_id>",
"uploads": [
{
"object": "transfer_upload",
"id": "<upload_id>",
"name": "brief.pdf",
"size": 1048576,
"method": "put",
"url": "https://….s3.amazonaws.com/…",
"part_size": null,
"status": "pending"
},
{
"object": "transfer_upload",
"id": "<upload_id>",
"name": "master.mov",
"size": 3221225472,
"method": "multipart",
"url": null,
"part_size": 67108864,
"status": "pending"
}
]
}The counters read 0 and size is 0 until the draft is finalized — nothing has been
packaged yet. Every quota check has already happened, though: the plan's transfer allowance
was tested once, up front, against the declared upload bytes plus the Brault content, so a
draft that opened will not be refused later for size or quota.
A single PUT (up to 256 MiB)
us.api.brault.app— follows the region selector in the top barcurl -X PUT "<url from the upload>" --data-binary @brief.pdf
curl -X POST https://us.api.brault.app/v1/transfers/kR7mN2pQ5vL/uploads/<upload_id>/complete \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'Send {} as the body: a POST with no body at all carries no Content-Length, and the API
answers 400 invalid_request to bodies it cannot size (see Conventions).
{ "object": "transfer_upload", "id": "<upload_id>", "status": "completed" }The server checks the object is actually there and that its size equals the size you
declared. A missing object answers 409 conflict; a different size answers 409 conflict with
details.reason: "size_mismatch" — PUT the right bytes and call complete again.
Multipart (above 256 MiB)
Ask for the part URLs, PUT each part, keep every ETag response header, then send them all:
us.api.brault.app— follows the region selector in the top barcurl -X POST https://us.api.brault.app/v1/transfers/kR7mN2pQ5vL/uploads/<upload_id>/parts \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "part_numbers": [1, 2, 3] }'{
"object": "upload_parts",
"parts": [
{ "part_number": 1, "upload_url": "https://….s3.amazonaws.com/…" },
{ "part_number": 2, "upload_url": "https://….s3.amazonaws.com/…" },
{ "part_number": 3, "upload_url": "https://….s3.amazonaws.com/…" }
]
}us.api.brault.app— follows the region selector in the top barcurl -X POST https://us.api.brault.app/v1/transfers/kR7mN2pQ5vL/uploads/<upload_id>/complete \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "parts": [
{ "part_number": 1, "etag": "\"9b2cf5…\"" },
{ "part_number": 2, "etag": "\"1ad0e3…\"" },
{ "part_number": 3, "etag": "\"77c8b1…\"" }
] }'Split the file at exactly part_size bytes (the last part may be smaller) — the server
chose that number, and S3 rejects a part list that does not match.
Finalize
us.api.brault.app— follows the region selector in the top barcurl -X POST https://us.api.brault.app/v1/transfers/kR7mN2pQ5vL/complete \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f14e45f-3333-4c1a-9c1e-transfer-finalize" \
-d '{}'Every declared upload must be completed first; otherwise the call answers
409 transfer_uploads_pending and details.pending names the ones still outstanding. On
success the response is the finished transfer, status: "processing", with its files and
folders.
A refusal that arrives before the transfer is written — a pending upload, a missing object, a plan or content refusal — leaves the draft open, so fix the cause and call complete again. A refusal that arrives after it is written, which only the password and expiry step can produce (the plan changed while the draft was open, or the expiry had already been moved), expires the transfer it just created and deletes the draft with it: there is nothing left to retry, so create a new transfer.
3. Wait for the zip
status moves processing → ready when the zip finishes, and zip_url appears with it.
Two ways to find out:
Poll GET /v1/transfers/<id> until status is ready. Every read signs a fresh
zip_url and none of them is stored, so read the transfer again when you need a link rather
than caching one.
Subscribe to the transfer.ready webhook instead, and read the transfer once when it
arrives. See Integrate webhooks; the four transfer events
are transfer.created, transfer.ready, transfer.downloaded and transfer.expired.
{
"object": "transfer",
"id": "kR7mN2pQ5vL",
"url": "https://acme.brault.app/d/kR7mN2pQ5vL",
"status": "ready",
"size": 4269801472,
"file_count": 3,
"folder_count": 0,
"password_protected": false,
"expires_at": "2026-09-14T10:00:00.000Z",
"view_count": 4,
"download_count": 1,
"zip_url": "https://cdn.brault.app/…/btransfer_acme_2026-09-07_10-00.zip?Signature=…",
"created_at": "2026-09-07T10:00:00.000Z",
"updated_at": "2026-09-07T10:04:12.000Z",
"created_by_id": "<user_id>",
"files": [
{ "name": "brief.pdf", "size": 1048576, "mime": "application/pdf", "path": "brief.pdf" },
{ "name": "master.mov", "size": 3221225472, "mime": "video/quicktime", "path": "master.mov" }
],
"folders": []
}Send url to the recipient — the API never e-mails anyone. zip_url is a direct download
for the whole package; url is the page a person opens.
4. Change the expiry or the password, and end it early
us.api.brault.app— follows the region selector in the top barcurl -X PATCH https://us.api.brault.app/v1/transfers/kR7mN2pQ5vL \
-H "Authorization: Bearer $BRAULT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "expires_in_days": 3, "password": null }'expires_in_days on a PATCH counts from today, and the expiry can be moved once per
transfer — a second attempt answers 409 conflict. "password": null removes the password;
a string sets one. A transfer that has already expired answers 410 transfer_expired.
us.api.brault.app— follows the region selector in the top barcurl -X DELETE https://us.api.brault.app/v1/transfers/kR7mN2pQ5vL \
-H "Authorization: Bearer $BRAULT_API_KEY"{ "object": "deleted", "id": "kR7mN2pQ5vL", "deleted": true }That expires the transfer immediately — recipients lose access at once. On a draft it cancels the upload and releases what was reserved instead.
What to expect afterwards
An expired transfer is deleted for good, and so is one whose zip could not be built: the id
stops resolving and GET /v1/transfers/<id> answers 404. Treat a 404 on an id you
created as "gone", and subscribe to transfer.expired if you need to know the moment it
happens. GET /v1/transfers lists the brandspace's transfers newest first, with any open
drafts among them; paging is bounded at 2,000 rows (offset + limit), and a cursor that
would read past that answers 400 invalid_request.