Skip to content

File Upload Guide

Learn how to upload bank statements to Laminr for processing.

Overview

Files are attached to packages. A package is a container that groups related documents together (e.g., all documents for a single loan application).

Workflow:

  1. Create a package
  2. Upload one or more files and attach them to the package
  3. Monitor processing status
  4. Retrieve results

Uploads use the v2 package API, which runs files through Laminr's asynchronous processing pipeline. See the Getting Started guide for a complete walkthrough, including how to create a package.

Supported File Types

Laminr currently processes bank statements. Accepted uploads are PDF files, or ZIP archives containing PDFs.

  • Bank statements: PDF

When you upload a .zip, Laminr extracts and processes its PDF members; non-PDF members are skipped. A ZIP with no extractable PDFs — or any upload that is neither a PDF nor a ZIP — is rejected as unsupported.

File requirements:

  • PDF files, or ZIP archives containing PDFs — other formats are not processed
  • Maximum file size: 1 GB per file
  • For scanned statements, use at least 300 DPI for best extraction accuracy

Best Quality

For best extraction accuracy, use high-quality PDFs generated directly from the financial institution rather than scanned documents.

Upload Files to a Package

Attaching a file to a package is a three-step process. Steps 1–2 put the file bytes into cloud storage; step 3 attaches the uploaded file to your package so Laminr starts processing it.

You'll need a package first — create one with POST /api/v2/packages (see Getting Started). This example uses the package LP-2025-001.

Step 1: Get a Presigned Upload URL

Endpoint:

POST /api/v1/files

Request:

curl -X POST https://api.laminr.ai/api/v1/files \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"file_name": "bank_statement.pdf"}'

Parameters:

Parameter Type Required Description
file_name string Yes The name of the file you want to upload
content_type string No The file's MIME type. Defaults to application/pdf, so you can leave it unset for PDF uploads. Pass application/zip when uploading a ZIP archive

Response:

{
  "upload_url": "https://storage.example.com/presigned-url-with-credentials...",
  "uri": "tenants/123/files/1699123456-789-bank-statement-pdf"
}

The upload_url is a temporary URL that lets you upload directly to cloud storage. The uri is the permanent identifier for the file that you'll use in Step 3.

Match the content type

The upload_url is generated for the content_type from Step 1 (application/pdf by default). Use the same Content-Type header when uploading in Step 2, otherwise the storage service may reject the upload. The default is correct for PDF uploads; for a ZIP archive, pass content_type: "application/zip" in Step 1 and send Content-Type: application/zip in Step 2.

Step 2: Upload File to Presigned URL

The upload_url from Step 1 is a resumable upload session. Cloud storage only finalizes the object once a request declares the file's total size, so your PUT must include a Content-Range header. For a single-request upload, send the whole file and set the range to span it:

SIZE=$(wc -c < /path/to/bank_statement.pdf)
curl -X PUT "https://storage.example.com/presigned-url-with-credentials..." \
  --upload-file /path/to/bank_statement.pdf \
  -H "Content-Type: application/pdf" \
  -H "Content-Range: bytes 0-$((SIZE - 1))/$SIZE"

Direct upload, resumable session

This PUT goes directly to cloud storage, not through the Laminr API. Because the session is resumable, large files can instead be streamed in chunks — each non-final chunk a multiple of 256 KiB — using successive Content-Range headers (bytes 0-8388607/<total>, bytes 8388608-…, and so on). That is how the Laminr web app uploads. A single PUT that declares the full size, as above, finalizes the object in one request.

Step 3: Attach the File to Your Package

Attach the uploaded file to your package with a PATCH to the v2 package endpoint. This registers the file and starts the processing pipeline.

Endpoint:

PATCH /api/v2/packages/{package_id}

Request:

curl -X PATCH https://api.laminr.ai/api/v2/packages/LP-2025-001 \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_names": ["bank_statement.pdf"],
    "file_uris": ["tenants/123/files/1699123456-789-bank-statement-pdf"]
  }'

Parameters:

Parameter Type Required Description
file_names array[string] Yes File names, in the same order as file_uris. Up to 100 per request.
file_uris array[string] Yes The URIs returned from Step 1, in the same order as file_names.
title string No Update the package title
loan_number string No Update the package loan number

Attaching more files

Each PATCH adds the listed files to the package — call it again to attach more. Re-sending a file name that already exists on the package updates that file's URI instead of creating a duplicate. Within a single request, file_names and file_uris must each be unique and equal in length.

Response: the updated package.

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "title": "John Doe Application",
  "public_id": "LP-2025-001",
  "status": "Processing",
  "progress": 0.0,
  "under_review": false,
  "created_at": "2025-11-05T10:00:00.000000+00:00",
  "updated_at": "2025-11-05T10:05:00.000000+00:00"
}

Upload Multiple Files

Attach several files in a single PATCH by passing parallel file_names and file_uris arrays (up to 100 per request). Get a presigned URL and upload each file first (Steps 1–2), collect the uris, then attach them together. Remember each PUT needs its own Content-Range (see Step 2):

# For each file: get a presigned URL (Step 1) and PUT the bytes (Step 2)
curl -X POST https://api.laminr.ai/api/v1/files \
  -H "x-api-key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"file_name": "bank_statement_jan.pdf"}'
# -> returns upload_url + uri; then:
SIZE=$(wc -c < bank_statement_jan.pdf)
curl -X PUT "<upload_url>" --upload-file bank_statement_jan.pdf \
  -H "Content-Type: application/pdf" -H "Content-Range: bytes 0-$((SIZE - 1))/$SIZE"

curl -X POST https://api.laminr.ai/api/v1/files \
  -H "x-api-key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"file_name": "bank_statement_feb.pdf"}'
# -> returns upload_url + uri; then:
SIZE=$(wc -c < bank_statement_feb.pdf)
curl -X PUT "<upload_url>" --upload-file bank_statement_feb.pdf \
  -H "Content-Type: application/pdf" -H "Content-Range: bytes 0-$((SIZE - 1))/$SIZE"

# Attach both files to the package in one request (Step 3)
curl -X PATCH https://api.laminr.ai/api/v2/packages/LP-2025-001 \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_names": ["bank_statement_jan.pdf", "bank_statement_feb.pdf"],
    "file_uris": ["<uri_jan>", "<uri_feb>"]
  }'

Each file is processed independently.

Check Processing Status

After you attach files, they go through several processing stages:

Status Description
queued File has been attached and is waiting to be processed
processing Document is being analyzed and data extracted
processed Processing finished successfully

A file may also report corrupt (the PDF could not be read), unsupported (not a PDF or a ZIP of PDFs), or duplicate_bytes (a byte-for-byte duplicate of another file in the package).

Check Package Status

Get the overall status of a package:

curl https://api.laminr.ai/api/v2/packages/LP-2025-001 \
  -H "x-api-key: YOUR_API_KEY"

Response:

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "tenant": {
    "id": "tenant_123",
    "name": "Your Company"
  },
  "title": "John Doe Application",
  "public_id": "LP-2025-001",
  "created_at": "2025-11-05T10:00:00.000000+00:00",
  "updated_at": "2025-11-05T10:05:30.000000+00:00",
  "status": "Processing",
  "created_by": {
    "id": "user_123",
    "email": "you@example.com"
  },
  "progress": 0.5,
  "under_review": false
}

To see individual file statuses, list the package's source files:

curl https://api.laminr.ai/api/v2/packages/LP-2025-001/source-files \
  -H "x-api-key: YOUR_API_KEY"

Response:

[
  {
    "id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
    "file_name": "bank_statement.pdf",
    "status": "processing",
    "page_count": 12,
    "duplicate_of_source_file_id": null,
    "created_at": "2025-11-05T10:05:00.000000+00:00",
    "updated_at": "2025-11-05T10:05:30.000000+00:00",
    "files": [
      {
        "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "file_name": "bank_statement.pdf",
        "status": "processing",
        "file_content_type": "application/pdf",
        "progress": 0.5
      }
    ]
  }
]

The package status will be one of:

  • "Pending": The package has no files to process yet
  • "Processing": Files are still being processed
  • "Processed": All files have been processed successfully
  • "Under Review": At least one file or summary errored and the package needs manual review

The progress field (0.0 to 1.0) indicates overall package processing progress.

Error Handling

Validation and rate-limit errors are returned as a flat JSON envelope with the following shape:

{
  "code": 400,
  "error": "FileURIError",
  "message": "File URI is not scoped to the loan package's tenant",
  "detail": "File URI is not scoped to the loan package's tenant"
}
Field Description
code The HTTP status code (also returned as the response status)
error The error class name (e.g. FileURIError, RateLimitedError)
message A human-readable description of what went wrong
detail Additional detail; usually identical to message

Common upload errors:

HTTP status error When it happens
400 FileValidationError * Missing, mismatched, duplicated, or too many (>100) file_names / file_uris, or a uri not scoped to your tenant
404 The package ID does not exist or isn't visible to your API key. Body is {"detail": "Not found."}, not the envelope above
429 RateLimitedError Too many requests. Honor the Retry-After response header before retrying

* Validation errors surface the specific FileValidationError subclass in the error field — for example FileURIError (URI not scoped to the tenant), FileBatchSizeError (more than 100 files), or FileBatchDuplicateNamesError / FileBatchDuplicateURIsError (duplicates within the request).

Processing Errors

If a file cannot be processed, its source-file status reflects the failure (for example corrupt or unsupported). List the package's source files to see the state:

[
  {
    "id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
    "file_name": "bank_statement.pdf",
    "status": "corrupt",
    "page_count": null,
    "duplicate_of_source_file_id": null,
    "created_at": "2025-11-05T10:05:00.000000+00:00",
    "updated_at": "2025-11-05T10:06:00.000000+00:00",
    "files": []
  }
]

When a file errors, the package status moves to "Under Review" so it can be inspected manually.

Best Practices

File Quality

  • Upload PDFs, or ZIP archives containing PDFs — other formats are not processed
  • Prefer text-based PDFs from the financial institution over scans
  • Ensure text is readable (not too blurry or low resolution)
  • For scanned statements, use at least 300 DPI

Batch Uploads

Upload files concurrently, then attach them in one request to save time:

import asyncio
import os

import aiohttp
import requests

API_KEY = 'your_api_key_here'
BASE_URL = 'https://api.laminr.ai/api'


async def upload_one(session, filepath, api_key):
    """Get a presigned URL and PUT the file bytes. Returns (file_name, uri)."""
    headers = {'x-api-key': api_key}

    # Step 1: Get presigned URL
    async with session.post(
        f'{BASE_URL}/v1/files',
        json={'file_name': filepath},
        headers=headers
    ) as response:
        data = await response.json()

    # Step 2: Upload to the resumable session. Content-Range declares the total
    # size so cloud storage finalizes the object; stream the file handle instead
    # of buffering it, and set Content-Length so the body isn't sent chunked.
    total = os.path.getsize(filepath)
    with open(filepath, 'rb') as f:
        async with session.put(
            data['upload_url'],
            data=f,
            headers={
                'Content-Type': 'application/pdf',
                'Content-Length': str(total),
                'Content-Range': f'bytes 0-{total - 1}/{total}',
            },
        ) as response:
            response.raise_for_status()  # fail fast if the upload didn't finalize
            await response.read()

    return filepath, data['uri']


async def upload_all(filepaths, api_key):
    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(*(upload_one(session, fp, api_key) for fp in filepaths))


# Upload 3 files concurrently, then attach them to the package in one PATCH
files = ['statement1.pdf', 'statement2.pdf', 'statement3.pdf']
uploaded = asyncio.run(upload_all(files, API_KEY))

requests.patch(
    f'{BASE_URL}/v2/packages/LP-2025-001',
    headers={'x-api-key': API_KEY},
    json={
        'file_names': [name for name, _ in uploaded],
        'file_uris': [uri for _, uri in uploaded],
    },
)

Monitoring Progress

Poll the package status endpoint to monitor processing:

import time
import requests

def wait_for_completion(package_id):
    headers = {'x-api-key': API_KEY}

    while True:
        response = requests.get(
            f'https://api.laminr.ai/api/v2/packages/{package_id}',
            headers=headers
        )
        package = response.json()

        if package['status'] == 'Processed':
            print("Processing complete!")
            return package
        elif package['status'] == 'Under Review':
            print("Package needs manual review (a file may have failed).")
            return package

        # Show progress (0.0 to 1.0)
        progress = package['progress']
        print(f"Progress: {progress * 100:.0f}%")

        time.sleep(5)  # Wait 5 seconds before checking again

result = wait_for_completion('LP-2025-001')

Bank Statements

  • Extract transactions, balances, and account information
  • Supports most major US banks
  • Best results with statements covering 2–3 months

Next Steps

Support

Need help? Contact us at support@laminr.ai