Skip to content

Documents (v2)

The v2 document API lets integrators upload, fetch, and manage the documents attached to a LightReach account. It replaces the legacy v1 multipart upload with a direct-to-storage, signed-URL flow: instead of streaming file bytes through our API, you request short-lived upload URLs, PUT your files straight to those URLs, and then watch an asynchronous job complete.

This guide assumes you already have an access token and an account ID. If you don’t, start with the Getting Started guide.

v1 (/api/accounts/:accountId/documents)v2 (/api/v2/accounts/:accountId/documents)
Upload transportmultipart/form-data streamed through our APIJSON init + direct PUT to signed upload URLs
CompletionSynchronous — response returns when persistedAsynchronous — returns a jobId; track via SSE or polling
Read URLsStreaming proxy onlyTime-limited signed URLs (plus a proxy for sensitive docs)
Image variantsNot includedThumbnails, previews, and gallery renditions included

v1 is deprecated. It still works today, but it will be removed in a future release — build new integrations on v2, and migrate existing ones when you can. Beyond longevity, v2 removes our API from the upload data path, which is faster and more reliable for large files.

All endpoints are rooted at:

${envBaseUrl}/api/v2/accounts/{accountId}/documents

envBaseUrl depends on your environment — see Environments. Every request must carry your bearer token:

headers: {
Authorization: `Bearer ${accessToken}`,
}

Requests are scoped to the account in the path. A token that cannot access {accountId} receives a 403.

Uploading is a three-step flow:

  1. Initialize the upload to get a jobId and a signed uploadUrl for each file.
  2. PUT each file directly to its signed URL.
  3. Track the job until it reports complete (or imageVariantsReady).

These three steps are the API contract, but you’ll typically wrap them in a single upload function (init → PUT each file → await completion) so callers in your application just pass files and get back documents.

POST /api/v2/accounts/{accountId}/documents

Send metadata describing the files you intend to upload — not the bytes themselves.

import fetch from 'node-fetch';
const response = await fetch(`${envBaseUrl}/api/v2/accounts/${accountId}/documents`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [
{ filename: 'utility-bill.pdf', size: 248120 },
{ filename: 'roof-photo.jpg', size: 1840233 },
],
type: 'utilityBill', // the document type; see the API reference for the full enum
fields: {
// optional metadata — see "Upload options" below
externalReference: 'your-system-id-123',
grouped: false, // true => all files become one multi-page document
},
}),
});
const { jobId, status, files } = await response.json();

Request body

FieldTypeRequiredNotes
filesarrayyes1–25 files. Each item needs filename and size (bytes). contentType is accepted but ignored — the server derives the canonical MIME type from the file extension.
typestringyes*The document type (e.g. utilityBill). Must be a valid type — see the API Reference. Can also be supplied as fields.type.
fieldsobjectnoOptional upload options — grouped and externalReference. See Upload options below.

* Required unless the upload is tied to a server-side stipulation context.

The optional fields object refines how the upload is stored:

KeyTypeDescription
groupedbooleanWhether multiple files become one document or many. See below.
externalReferencestring | numberYour own identifier for the document. Stored on the document, returned on reads, and included in the documentUploaded webhook so you can correlate it back to your system.

externalReference is applied to every document the upload creates.

When an upload contains more than one file, grouped decides how those files are turned into documents:

  • grouped: true — all files become a single document with multiple files (for example, the pages of one scanned utility bill).
  • grouped: false — each file becomes its own document of the same type.
  • Omitted — falls back to a per-type default: a few document types always split into separate documents, while the rest group into one. Set grouped explicitly whenever the outcome matters to you rather than relying on the default.

grouped only affects multi-file uploads — a single-file upload always produces one document.

Response — 202 Accepted

{
jobId: 'V1StGXR8_Z5jdHi6B-myT',
status: 'awaitingUpload',
files: [
{
filename: 'utility-bill.pdf',
stagingPath: 'staging/V1StGXR8.../abc123.pdf',
uploadUrl: 'https://signed-upload-url/...',
expiresAt: '2025-01-01T22:23:28.404Z',
contentType: 'application/pdf',
},
// ...one entry per requested file, in the same order
],
}

Keep the jobId — you need it to track progress. Each uploadUrl is valid for 60 minutes.

Step 2 — Upload each file to its signed URL

Section titled “Step 2 — Upload each file to its signed URL”

PUT the raw file bytes to the uploadUrl. Do not send your bearer token to the upload URL; the signed URL is already authorized. You must set the Content-Type header to the contentType returned in step 1 — the signature is bound to it, and any other value is rejected with a 403.

import fetch from 'node-fetch';
import { readFile } from 'node:fs/promises';
await Promise.all(
files.map(async (f) => {
const body = await readFile(localPathFor(f.filename));
const res = await fetch(f.uploadUrl, {
method: 'PUT',
body,
headers: { 'Content-Type': f.contentType },
});
if (!res.ok) {
throw new Error(`Upload failed for ${f.filename}: ${res.status}`);
}
}),
);

You can upload the files in parallel. Nothing is persisted in LightReach until the bytes land in storage and the job completes.

After your PUTs succeed, a background worker detects the files, persists the document(s), and generates image variants. Watch for completion using either approach below.

The job emits these event types:

Event typeMeaning
queued / awaitingUploadJob created; still waiting on the files.
completeDocuments persisted with real IDs and original viewUrls. This is the success signal you should act on. Carries a documents array.
imageVariantsReadyThumbnails/previews/gallery renditions finished. Also carries documents. May arrive before or after complete.
failedUpload failed. Carries an error message.
Section titled “Option A — Server-Sent Events (recommended)”

GET /api/v2/accounts/{accountId}/documents/upload-progress?jobId={jobId}

Opens an SSE stream that pushes events as they happen and replays the latest cached event on connect (so you won’t miss one if you connect late). The stream closes on a terminal event (imageVariantsReady or failed) and sends a heartbeat every 30 seconds.

import EventSource from 'eventsource';
const url = `${envBaseUrl}/api/v2/accounts/${accountId}/documents/upload-progress?jobId=${encodeURIComponent(jobId)}`;
const sse = new EventSource(url, { headers: { Authorization: `Bearer ${accessToken}` } });
const documents = await new Promise((resolve, reject) => {
sse.onmessage = (e) => {
const event = JSON.parse(e.data);
if (event.type === 'complete' || event.type === 'imageVariantsReady') {
sse.close();
resolve(event.documents);
} else if (event.type === 'failed') {
sse.close();
reject(new Error(event.error));
}
};
sse.onerror = () => {
sse.close();
reject(new Error('SSE connection error — fall back to polling'));
};
});

GET /api/v2/accounts/{accountId}/documents/jobs/{jobId}

Returns the latest progress event for the job as a plain JSON object. Poll every couple of seconds until you see a terminal type. Useful where SSE isn’t practical (and a good fallback if an SSE connection drops).

import fetch from 'node-fetch';
async function pollUntilDone(accountId, jobId) {
while (true) {
const res = await fetch(
`${envBaseUrl}/api/v2/accounts/${accountId}/documents/jobs/${jobId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const event = await res.json();
if (event.type === 'complete' || event.type === 'imageVariantsReady') return event.documents;
if (event.type === 'failed') throw new Error(event.error);
await new Promise((r) => setTimeout(r, 2000));
}
}

A 404 from this endpoint means the job doesn’t exist or doesn’t belong to your account.

GET /api/v2/accounts/{accountId}/documents

Query paramTypeNotes
archivedbooleantrue returns only archived documents. Defaults to false.
sortstringID_DESC (newest first) or ID_ASC.
import fetch from 'node-fetch';
const res = await fetch(
`${envBaseUrl}/api/v2/accounts/${accountId}/documents?sort=ID_DESC`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const documents = await res.json();

GET /api/v2/accounts/{accountId}/documents/{documentId}

Returns the document with freshly signed read URLs. Responds 404 if the document doesn’t exist or doesn’t belong to the account.

Both fetch endpoints (and the upload documents array) return objects of this shape:

{
id: 'string',
accountId: 'string',
applicationId: 'string',
status: 'pending', // 'pending' | 'approved' | 'rejected'
archived: false,
type: 'utilityBill',
externalReference: 'your-system-id-123',
files: [
{
originalName: 'utility-bill.pdf',
contentType: 'application/pdf',
md5Hash: 'string',
sizeKB: 242,
viewUrls: [
{ url: 'https://...', type: 'original', expiresAt: 'ISO datetime' },
{ url: 'https://...', type: 'preview', expiresAt: 'ISO datetime' },
{ url: 'https://...', type: 'gallery', expiresAt: 'ISO datetime' },
{ url: 'https://...', type: 'thumbnail', expiresAt: 'ISO datetime' },
],
},
],
meta: { createdAt: 'ISO datetime', updatedAt: 'ISO datetime' },
}

About viewUrls:

  • The original entry is always present. preview, gallery, and thumbnail variants appear only for supported file types and only once variant generation has finished (right after upload, a document may have just the original).
  • For most documents these are time-limited signed URLs (roughly 20–60 minutes depending on sensitivity), and each carries an expiresAt timestamp telling you exactly when it stops working. Highly sensitive documents instead return a streaming-proxy URL on our domain that requires your bearer token; those don’t expire, so expiresAt is omitted.
  • Fetch the file bytes before expiresAt. To keep a URL usable past then, re-fetch the document or use the refresh endpoint below.

These short lifetimes are a deliberate security measure: a signed URL grants direct access to the file to anyone who holds it for as long as it’s valid, so the window is kept small — and shorter for more sensitive document types — to limit exposure if a URL leaks. Treat signed URLs as sensitive: don’t log, cache, or share them, request fresh ones when you need them, and rely on expiresAt rather than reusing a URL indefinitely.

POST /api/v2/accounts/{accountId}/documents/{documentId}/refresh-urls

Force-regenerates all signed URLs for a document (and its variants), bypassing any cached values. Returns the document in the same shape as above. Use this when a URL has expired client-side rather than re-listing the whole account.

import fetch from 'node-fetch';
const res = await fetch(
`${envBaseUrl}/api/v2/accounts/${accountId}/documents/${documentId}/refresh-urls`,
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } },
);
const document = await res.json();

DELETE /api/v2/accounts/{accountId}/documents/{documentId}

Soft-deletes (archives) a document. Returns 204 No Content. Archived documents stop appearing in the default list and can be retrieved with ?archived=true.

import fetch from 'node-fetch';
await fetch(`${envBaseUrl}/api/v2/accounts/${accountId}/documents/${documentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
StatusWhen
400Missing/empty files, more than 25 files, missing filename, an unsupported file extension, or an invalid document type.
403The token can’t access the account, or a PUT to an upload URL used the wrong Content-Type.
404Unknown document or job, or one belonging to a different account.
503Signed-URL uploads aren’t available in this environment — fall back to the v1 multipart endpoint (POST /api/accounts/:accountId/documents).

Other notes:

  • Max 25 files per upload job. Split larger batches into multiple POST inits.
  • Allowed file extensions are validated server-side against an allowlist; unsupported extensions are rejected at init with the list of allowed values.
  • Standard Finance API rate limits apply.
import fetch from 'node-fetch';
import { readFile } from 'node:fs/promises';
async function uploadDocument(envBaseUrl, accessToken, accountId, filePath, type) {
const auth = { Authorization: `Bearer ${accessToken}` };
const bytes = await readFile(filePath);
const filename = filePath.split('/').pop();
// 1. Initialize
const initRes = await fetch(`${envBaseUrl}/api/v2/accounts/${accountId}/documents`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ files: [{ filename, size: bytes.length }], type }),
});
if (!initRes.ok) throw new Error(`Init failed: ${initRes.status}`);
const { jobId, files } = await initRes.json();
// 2. PUT to the signed URL
const [signed] = files;
const putRes = await fetch(signed.uploadUrl, {
method: 'PUT',
body: bytes,
headers: { 'Content-Type': signed.contentType },
});
if (!putRes.ok) throw new Error(`Upload failed: ${putRes.status}`);
// 3. Poll for completion
while (true) {
const res = await fetch(`${envBaseUrl}/api/v2/accounts/${accountId}/documents/jobs/${jobId}`, {
headers: auth,
});
const event = await res.json();
if (event.type === 'complete' || event.type === 'imageVariantsReady') return event.documents;
if (event.type === 'failed') throw new Error(event.error);
await new Promise((r) => setTimeout(r, 2000));
}
}

For exact request/response schemas and the full list of document types, see the Finance API Reference.