> ## Documentation Index
> Fetch the complete documentation index at: https://docs.layerfi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Multipart Upload Guide

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**](/api-reference/v1/create-document-upload) — register the document and get presigned URLs for each part.
2. [**Complete**](/api-reference/v1/complete-document-upload) — finalize the upload once every part is uploaded.
3. [**Abort**](/api-reference/v1/abort-document-upload) — cancel the upload and discard any uploaded parts.

<Steps>
  <Step title="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).

    ```json Request theme={null}
    {
      "document_type": "OTHER",
      "file_name": "LargeZip.zip",
      "file_type": "application/zip",
      "file_size_bytes": 167773989
    }
    ```

    ```json Response (201) theme={null}
    {
      "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`.
  </Step>

  <Step title="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.

    <Warning>
      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.
    </Warning>

    <Tip>
      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.
    </Tip>
  </Step>

  <Step title="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.

    ```json Request theme={null}
    {
      "upload_id": "wSHGMiqwSlI7jq_Sbqe...",
      "parts": [
        { "part_number": 1, "etag": "5384e6f5dc735116fc4732ab74fa8398" },
        { "part_number": 2, "etag": "6494f12f6382e115546a1a6f1462a6ae" }
      ]
    }
    ```

    ```json Response (200) theme={null}
    {
      "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=..."
    }
    ```
  </Step>
</Steps>

## Aborting an upload

If an upload fails partway through or is no longer needed, call [Abort](/api-reference/v1/abort-document-upload) to release the uploaded parts. Pass the `upload_id` from the create step:

```json Request theme={null}
{
  "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](/api-reference/v1/create-business-auth-token).

<CodeGroup>
  ```bash cURL theme={null}
  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}"
  ```

  ```python Python theme={null}
  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"])
  ```

  ```javascript JavaScript theme={null}
  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 PHP theme={null}
  <?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"];
  ```

  ```go Go theme={null}
  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 Java theme={null}
  // 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
  ```

  ```ruby Ruby theme={null}
  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"]}"
  ```
</CodeGroup>

<Note>
  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`).
</Note>
