PUT the file bytes straight to S3, and then tell Layer to assemble the parts. This keeps large files off Layer’s servers and supports files that exceed standard single-request limits.
The flow uses three endpoints:
- Create — register the document and get presigned URLs for each part.
- Complete — finalize the upload once every part is uploaded.
- Abort — cancel the upload and discard any uploaded parts.
1
Create the upload
POST /v1/businesses/{businessId}/documents with the document metadata. The response contains the document_id, the S3 upload_id, the part_size_bytes each part must be, and a parts array of presigned URLs (one per part).Request
{
"document_type": "OTHER",
"file_name": "LargeZip.zip",
"file_type": "application/zip",
"file_size_bytes": 167773989
}
Response (201)
{
"document_id": "86e30ada-1f5d-4b44-84f1-950a7c75ab33",
"upload_id": "wSHGMiqwSlI7jq_Sbqe...",
"part_size_bytes": 8388608,
"parts": [
{ "part_number": 1, "url": "https://s3.../part-1?X-Amz-Signature=..." },
{ "part_number": 2, "url": "https://s3.../part-2?X-Amz-Signature=..." }
]
}
document_type is one of RECEIPT, UNSTRUCTURED_BOOKKEEPING_CONTEXT, or OTHER.2
Upload each part to S3
Split the file into chunks of exactly
part_size_bytes (the final part may be smaller) and PUT each chunk to its presigned url. These requests go directly to S3, not to the Layer API, and need no Authorization header.Capture the ETag response header from each PUT — you’ll need it to complete the upload.Keep each part the full
part_size_bytes (except the last), and pair every ETag with the correct part_number. A mismatched size or a missing/misnumbered ETag will cause the complete step to fail.Parts are independent and can be uploaded in any order and in parallel. The examples below loop sequentially for clarity, but in production upload several parts concurrently (a worker pool of ~4–8 is a good default) to cut total upload time significantly — just make sure each ETag stays matched to its
part_number, and sort the parts list by part_number before calling complete.3
Complete the upload
POST /v1/businesses/{businessId}/documents/{documentId}/complete with the upload_id and the collected { part_number, etag } pairs. S3 stitches the parts into the final object and Layer returns the stored document.Request
{
"upload_id": "wSHGMiqwSlI7jq_Sbqe...",
"parts": [
{ "part_number": 1, "etag": "5384e6f5dc735116fc4732ab74fa8398" },
{ "part_number": 2, "etag": "6494f12f6382e115546a1a6f1462a6ae" }
]
}
Response (200)
{
"id": "86e30ada-1f5d-4b44-84f1-950a7c75ab33",
"file_name": "LargeZip.zip",
"file_type": "application/zip",
"document_type": "OTHER",
"presigned_url": "https://s3.../LargeZip.zip?X-Amz-Signature=..."
}
Aborting an upload
If an upload fails partway through or is no longer needed, call Abort to release the uploaded parts. Pass theupload_id from the create step:
Request
{
"upload_id": "wSHGMiqwSlI7jq_Sbqe..."
}
POST /v1/businesses/{businessId}/documents/{documentId}/abort returns an empty 200 response.
End-to-end example
The following uploads a file through all three steps.access_token is a Bearer token obtained via scoped authentication.
BASE="https://api.layerfi.com"
BUSINESS_ID="..."
TOKEN="$ACCESS_TOKEN"
FILE="LargeZip.zip"
# 1. Create — get presigned part URLs
CREATE=$(curl -s -X POST "$BASE/v1/businesses/$BUSINESS_ID/documents" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"document_type\":\"OTHER\",\"file_name\":\"$FILE\",\"file_type\":\"application/zip\",\"file_size_bytes\":$(wc -c < "$FILE")}")
DOCUMENT_ID=$(echo "$CREATE" | jq -r .document_id)
UPLOAD_ID=$(echo "$CREATE" | jq -r .upload_id)
PART_SIZE=$(echo "$CREATE" | jq -r .part_size_bytes)
# 2. Split the file into parts and PUT each one to S3, capturing ETags.
# Use a fresh temp dir so stale chunks from a previous run can't leak in.
PARTS_DIR=$(mktemp -d)
trap 'rm -rf "$PARTS_DIR"' EXIT
split -b "$PART_SIZE" "$FILE" "$PARTS_DIR/part_"
PARTS="[]"; i=0
for chunk in "$PARTS_DIR"/part_*; do
i=$((i + 1))
URL=$(echo "$CREATE" | jq -r ".parts[$((i - 1))].url")
ETAG=$(curl -s -X PUT --data-binary "@$chunk" -D - -o /dev/null "$URL" \
| tr -d '\r' | awk -F'"' '/^[Ee][Tt][Aa][Gg]:/ {print $2}')
PARTS=$(echo "$PARTS" | jq ". + [{\"part_number\":$i,\"etag\":\"$ETAG\"}]")
done
# 3. Complete the upload
curl -s -X POST "$BASE/v1/businesses/$BUSINESS_ID/documents/$DOCUMENT_ID/complete" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"upload_id\":\"$UPLOAD_ID\",\"parts\":$PARTS}"
import os, requests
BASE = "https://api.layerfi.com"
business_id = "..."
file_path = "LargeZip.zip"
auth = {"Authorization": f"Bearer {access_token}"}
# 1. Create — get presigned part URLs
create = requests.post(
f"{BASE}/v1/businesses/{business_id}/documents",
headers={**auth, "Content-Type": "application/json"},
json={
"document_type": "OTHER",
"file_name": os.path.basename(file_path),
"file_type": "application/zip",
"file_size_bytes": os.path.getsize(file_path),
},
).json()
document_id = create["document_id"]
upload_id = create["upload_id"]
part_size = create["part_size_bytes"]
parts = sorted(create["parts"], key=lambda p: p["part_number"])
# 2. Upload each part directly to S3, capturing ETags
completed = []
with open(file_path, "rb") as f:
for p in parts:
chunk = f.read(part_size)
if not chunk:
break
put = requests.put(p["url"], data=chunk)
put.raise_for_status()
completed.append({
"part_number": p["part_number"],
"etag": put.headers["ETag"].strip('"'),
})
# 3. Complete the upload
doc = requests.post(
f"{BASE}/v1/businesses/{business_id}/documents/{document_id}/complete",
headers={**auth, "Content-Type": "application/json"},
json={"upload_id": upload_id, "parts": completed},
).json()
print("Uploaded document:", doc["id"])
import { readFileSync, statSync } from "fs";
const BASE = "https://api.layerfi.com";
const businessId = "...";
const filePath = "LargeZip.zip";
const auth = { Authorization: `Bearer ${accessToken}` };
// 1. Create — get presigned part URLs
const create = await fetch(`${BASE}/v1/businesses/${businessId}/documents`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
document_type: "OTHER",
file_name: "LargeZip.zip",
file_type: "application/zip",
file_size_bytes: statSync(filePath).size,
}),
}).then((r) => r.json());
const { document_id, upload_id, part_size_bytes, parts } = create;
// 2. Upload each part to S3, capturing ETags
const file = readFileSync(filePath);
const completed = [];
for (const { part_number, url } of parts.sort((a, b) => a.part_number - b.part_number)) {
const start = (part_number - 1) * part_size_bytes;
const chunk = file.subarray(start, start + part_size_bytes);
const res = await fetch(url, { method: "PUT", body: chunk });
completed.push({ part_number, etag: res.headers.get("etag").replaceAll('"', "") });
}
// 3. Complete the upload
const doc = await fetch(
`${BASE}/v1/businesses/${businessId}/documents/${document_id}/complete`,
{
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ upload_id, parts: completed }),
},
).then((r) => r.json());
console.log("Uploaded document:", doc.id);
<?php
$base = "https://api.layerfi.com";
$businessId = "...";
$filePath = "LargeZip.zip";
$auth = ["Authorization: Bearer $accessToken"];
function postJson($url, $auth, $payload) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge($auth, ["Content-Type: application/json"]),
CURLOPT_POSTFIELDS => json_encode($payload),
]);
return json_decode(curl_exec($ch), true);
}
// 1. Create — get presigned part URLs
$create = postJson("$base/v1/businesses/$businessId/documents", $auth, [
"document_type" => "OTHER",
"file_name" => "LargeZip.zip",
"file_type" => "application/zip",
"file_size_bytes" => filesize($filePath),
]);
$documentId = $create["document_id"];
$partSize = $create["part_size_bytes"];
// 2. Upload each part to S3, capturing ETags
$handle = fopen($filePath, "rb");
$completed = [];
foreach ($create["parts"] as $part) {
$chunk = fread($handle, $partSize);
$ch = curl_init($part["url"]);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "PUT",
CURLOPT_POSTFIELDS => $chunk,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
]);
$response = curl_exec($ch);
$headers = substr($response, 0, curl_getinfo($ch, CURLINFO_HEADER_SIZE));
preg_match('/etag:\s*"?([^"\r\n]+)"?/i', $headers, $m);
$completed[] = ["part_number" => $part["part_number"], "etag" => $m[1]];
}
fclose($handle);
// 3. Complete the upload
$doc = postJson("$base/v1/businesses/$businessId/documents/$documentId/complete", $auth, [
"upload_id" => $create["upload_id"],
"parts" => $completed,
]);
echo "Uploaded document: " . $doc["id"];
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
)
const base = "https://api.layerfi.com"
func main() {
businessID, filePath, token := "...", "LargeZip.zip", accessToken
info, _ := os.Stat(filePath)
// 1. Create — get presigned part URLs
body, _ := json.Marshal(map[string]any{
"document_type": "OTHER",
"file_name": "LargeZip.zip",
"file_type": "application/zip",
"file_size_bytes": info.Size(),
})
req, _ := http.NewRequest("POST", base+"/v1/businesses/"+businessID+"/documents", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
var create struct {
DocumentID string `json:"document_id"`
UploadID string `json:"upload_id"`
PartSizeBytes int64 `json:"part_size_bytes"`
Parts []struct {
PartNumber int `json:"part_number"`
URL string `json:"url"`
} `json:"parts"`
}
json.NewDecoder(resp.Body).Decode(&create)
sort.Slice(create.Parts, func(i, j int) bool { return create.Parts[i].PartNumber < create.Parts[j].PartNumber })
// 2. Upload each part to S3, capturing ETags
f, _ := os.Open(filePath)
defer f.Close()
type part struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
var completed []part
buf := make([]byte, create.PartSizeBytes)
for _, p := range create.Parts {
n, _ := io.ReadFull(f, buf)
put, _ := http.NewRequest("PUT", p.URL, bytes.NewReader(buf[:n]))
pr, _ := http.DefaultClient.Do(put)
completed = append(completed, part{p.PartNumber, strings.Trim(pr.Header.Get("ETag"), "\"")})
}
// 3. Complete the upload
cb, _ := json.Marshal(map[string]any{"upload_id": create.UploadID, "parts": completed})
cReq, _ := http.NewRequest("POST", base+"/v1/businesses/"+businessID+"/documents/"+create.DocumentID+"/complete", bytes.NewReader(cb))
cReq.Header.Set("Authorization", "Bearer "+token)
cReq.Header.Set("Content-Type", "application/json")
cResp, _ := http.DefaultClient.Do(cReq)
out, _ := io.ReadAll(cResp.Body)
fmt.Println(string(out))
}
// Java 11+ (java.net.http). Use a JSON library (e.g. Jackson) to parse/build the
// create response and complete request; field access is shown in comments.
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.*;
var base = "https://api.layerfi.com";
var businessId = "...";
var filePath = Path.of("LargeZip.zip");
var client = HttpClient.newHttpClient();
// 1. Create — get presigned part URLs
var createBody = """
{"document_type":"OTHER","file_name":"LargeZip.zip","file_type":"application/zip","file_size_bytes":%d}"""
.formatted(Files.size(filePath));
var create = client.send(
HttpRequest.newBuilder(URI.create(base + "/v1/businesses/" + businessId + "/documents"))
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(createBody)).build(),
HttpResponse.BodyHandlers.ofString());
// Parse from create.body(): documentId, uploadId, partSize, parts[] (each part_number + url).
// 2. Upload each part to S3, capturing ETags
byte[] file = Files.readAllBytes(filePath);
List<Map<String, Object>> completed = new ArrayList<>();
for (var part : parts) { // for each {partNumber, url}
int start = (part.partNumber - 1) * partSize;
byte[] chunk = Arrays.copyOfRange(file, start, Math.min(start + partSize, file.length));
var put = client.send(
HttpRequest.newBuilder(URI.create(part.url))
.PUT(HttpRequest.BodyPublishers.ofByteArray(chunk)).build(),
HttpResponse.BodyHandlers.discarding());
String etag = put.headers().firstValue("etag").orElseThrow().replace("\"", "");
completed.add(Map.of("part_number", part.partNumber, "etag", etag));
}
// 3. Complete the upload — POST {"upload_id": uploadId, "parts": completed} (serialize with your JSON lib)
// to /v1/businesses/{businessId}/documents/{documentId}/complete
require "net/http"
require "json"
require "uri"
base = "https://api.layerfi.com"
business_id = "..."
file_path = "LargeZip.zip"
auth = { "Authorization" => "Bearer #{access_token}" }
# 1. Create — get presigned part URLs
create = Net::HTTP.post(
URI("#{base}/v1/businesses/#{business_id}/documents"),
{ document_type: "OTHER", file_name: "LargeZip.zip",
file_type: "application/zip", file_size_bytes: File.size(file_path) }.to_json,
auth.merge("Content-Type" => "application/json"))
data = JSON.parse(create.body)
document_id, upload_id, part_size = data["document_id"], data["upload_id"], data["part_size_bytes"]
# 2. Upload each part to S3, capturing ETags
completed = []
File.open(file_path, "rb") do |f|
data["parts"].sort_by { |p| p["part_number"] }.each do |part|
chunk = f.read(part_size)
break if chunk.nil?
uri = URI(part["url"])
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Put.new(uri)
req.body = chunk
http.request(req)
end
completed << { part_number: part["part_number"], etag: res["etag"].delete('"') }
end
end
# 3. Complete the upload
doc = Net::HTTP.post(
URI("#{base}/v1/businesses/#{business_id}/documents/#{document_id}/complete"),
{ upload_id: upload_id, parts: completed }.to_json,
auth.merge("Content-Type" => "application/json"))
puts "Uploaded document: #{JSON.parse(doc.body)["id"]}"
Presigned URLs are time-limited. Upload the parts and call complete promptly after creating the upload; if a URL expires, start over with a new create request (and optionally abort the old
upload_id).