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.
Why v2
Section titled “Why v2”v1 (/api/accounts/:accountId/documents) | v2 (/api/v2/accounts/:accountId/documents) | |
|---|---|---|
| Upload transport | multipart/form-data streamed through our API | JSON init + direct PUT to signed upload URLs |
| Completion | Synchronous — response returns when persisted | Asynchronous — returns a jobId; track via SSE or polling |
| Read URLs | Streaming proxy only | Time-limited signed URLs (plus a proxy for sensitive docs) |
| Image variants | Not included | Thumbnails, 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.
Base path and authentication
Section titled “Base path and authentication”All endpoints are rooted at:
${envBaseUrl}/api/v2/accounts/{accountId}/documentsenvBaseUrl 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 a document
Section titled “Uploading a document”Uploading is a three-step flow:
- Initialize the upload to get a
jobIdand a signeduploadUrlfor each file. PUTeach file directly to its signed URL.- Track the job until it reports
complete(orimageVariantsReady).
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.
Step 1 — Initialize the upload
Section titled “Step 1 — Initialize the upload”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
| Field | Type | Required | Notes |
|---|---|---|---|
files | array | yes | 1–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. |
type | string | yes* | The document type (e.g. utilityBill). Must be a valid type — see the API Reference. Can also be supplied as fields.type. |
fields | object | no | Optional upload options — grouped and externalReference. See Upload options below. |
* Required unless the upload is tied to a server-side stipulation context.
Upload options (fields)
Section titled “Upload options (fields)”The optional fields object refines how the upload is stored:
| Key | Type | Description |
|---|---|---|
grouped | boolean | Whether multiple files become one document or many. See below. |
externalReference | string | number | Your 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.
grouped — one document or many
Section titled “grouped — one document or many”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
groupedexplicitly 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.
Step 3 — Track the upload job
Section titled “Step 3 — Track the upload job”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 type | Meaning |
|---|---|
queued / awaitingUpload | Job created; still waiting on the files. |
complete | Documents persisted with real IDs and original viewUrls. This is the success signal you should act on. Carries a documents array. |
imageVariantsReady | Thumbnails/previews/gallery renditions finished. Also carries documents. May arrive before or after complete. |
failed | Upload failed. Carries an error message. |
Option A — Server-Sent Events (recommended)
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')); };});Option B — Polling
Section titled “Option B — 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.
Fetching documents
Section titled “Fetching documents”List all documents for an account
Section titled “List all documents for an account”GET /api/v2/accounts/{accountId}/documents
| Query param | Type | Notes |
|---|---|---|
archived | boolean | true returns only archived documents. Defaults to false. |
sort | string | ID_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 a single document
Section titled “Get a single document”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.
Document shape
Section titled “Document shape”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
originalentry is always present.preview,gallery, andthumbnailvariants appear only for supported file types and only once variant generation has finished (right after upload, a document may have just theoriginal). - For most documents these are time-limited signed URLs (roughly 20–60 minutes depending on sensitivity), and each carries an
expiresAttimestamp 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, soexpiresAtis 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.
Refresh expired URLs
Section titled “Refresh expired URLs”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();Archiving a document
Section titled “Archiving a document”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}` },});Errors and limits
Section titled “Errors and limits”| Status | When |
|---|---|
400 | Missing/empty files, more than 25 files, missing filename, an unsupported file extension, or an invalid document type. |
403 | The token can’t access the account, or a PUT to an upload URL used the wrong Content-Type. |
404 | Unknown document or job, or one belonging to a different account. |
503 | Signed-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
POSTinits. - 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.
End-to-end example
Section titled “End-to-end example”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.