Skip to main content
Documents are uploaded directly to S3 using a multipart upload. Layer issues presigned URLs for each part, you 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:
  1. Create — register the document and get presigned URLs for each part.
  2. Complete — finalize the upload once every part is uploaded.
  3. 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 the upload_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}"
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).