Docs / API and agents
Documents API
Upload, download and remove application documents, for signed-in users and for references with a private link.
Files live in private object storage. A file can be downloaded only after it passed a security scan; until then it shows as pending. A download returns a signed URL that lasts 60 seconds; a storage path is never returned.
Routes#
| Method and path | Who | What |
|---|---|---|
POST /applications/:id/documents | applicant who owns it, supplier with documents.upload in that organization, or staff; only while the application has no final decision | multipart upload |
GET /documents/:id | anyone allowed to read it (below) | { url, name } |
DELETE /documents/:id | the uploader, for their own unattached document, before a final decision | soft remove |
POST /documents/:id/review | supplier of that application with documents.review, never on their own upload | accept, reject or ask for more |
GET /documents/requirements | supplier with documents.view | the seven requirable types and their setting |
PUT /documents/requirements/:type | supplier with documents.requirements.manage | set never, optional or required for a type |
POST /reference/:token/documents | public, reference link token | upload evidence for that reference |
GET /reference/:token/documents/:id | public, link token | download a document of that reference |
Read access: staff; the supplier organization of the application with documents.view; the applicant who owns the application for their own uploads and for files shared in an applicant-visible message. A removed document reads as not found.
Upload rules#
At most 10 files and 4 MB in total per request. Every file is checked (size, declared type against its contents) before anything is stored; a mismatch is refused. Limits: 30 uploads per user per hour, 20 per IP per hour for reference links.
| Status | When |
|---|---|
| 403 | no permission to upload, a decision is already recorded, or removing something that is not your own unattached upload (message attachments and reference evidence are retained) |
| 404 | unknown or unreadable document, or an invalid reference link |
| 409 | download: the file is still pending its security scan (document_pending_scan), or it changed while signing the link |
| 410 | the reference link expired |
| 422 | download: the scanner rejected the file (document_rejected); upload a replacement |
| 503 | download: the scan could not be completed (document_scan_unavailable); contact support |
| 429 | upload limit |
| 503 | storage could not sign a link |
Types, versions and review#
An upload may carry a form field type: w9, tax_return, bank_letter, bank_statement, financials, business_license, insurance_certificate or other (the default). Any other value, and trade_reference_doc, is a 400. Message attachments are always other; files a reference uploads are always trade_reference_doc.
For a type an application holds once (everything above except other), a new upload is version n+1 and the file it replaces becomes superseded. Nothing is deleted and the old object stays.
A file's status follows its scan: scan_pending, then in_review (clean) or scan_rejected. A reviewer then sets it with POST /documents/:id/review { outcome, reasonCode?, note?, expiresAt? }:
| Outcome | Status | Notes |
|---|---|---|
accepted | accepted | for an insurance certificate expiresAt (a future date) starts the validity clock |
rejected, needs_more | rejected, needs_more | reasonCode is required: unreadable, wrong_document, incomplete, expired, name_mismatch, other |
Each review is an append-only row and an audit event document.reviewed (outcome and reason code, never the note). A file can be reviewed once while in_review; a second review is a 409. A file that is accepted, superseded or sealed cannot be removed. On a rejection or a request for more the customer gets an in-app notification and an email with the fixed sentence for the reason code (never the reviewer's note).
A daily job (document-expiry, 06:00) handles accepted files with an expiresAt: one notice at 30 days, one at 7 days and expired (status change, audit document.expired) when the date passes. Each notice reaches the customer (in-app and email) and, in-app, the supplier user who accepted the file; one notice per document and kind is ever sent, and a file first seen late gets only its most urgent notice.
Requirement changes are audited as document.requirement_set.
Generated copies#
Three documents are made by the system, not uploaded. When an application is submitted, a document.generate job renders application_pdf from the immutable submission record (answers, certification, and a "Principals and personal guaranty" section listing each owner or officer and the guaranty acknowledgement; signature images, technical fields and SSN digits are left out). When a decision is recorded, the same job renders a decision_notice (outcome, limit and terms, the principal reasons of a decline, the supplier's note, the signer's name). When a reference responds, a reference_response_pdf is rendered from the recorded revision (answers and attestation, no signature image). The applicant sees only their own application_pdf, decision_notice, credit_agreement and personal_guaranty; reference copies are for the supplier. The agreement and guaranty files (unsigned, then the signed copy with its certificate) are made by the signing flow and are attached to its request: they cannot be removed. The job is queued in the same transaction as the state change and runs after it, so a failed render never blocks a submission or a decision; it retries and then stays as a dead letter staff can requeue.
A generated file goes through the same storage and security scan as an upload, has no uploader, and is sealed as soon as the scan is clean (scan_rejected otherwise). A sealed document cannot change, be removed or be deleted. Download rules follow the paragraph above. It is audited as document.generated. The wording is a plain listing of the recorded facts; adverse action wording is counsel-owned and not final.
Reminders, retention and orphans#
A daily job (document-requirement-reminders, 06:15) emails and notifies the customer once per application and week while an application older than two days still lacks an accepted file for a type the supplier requires.
At the end of the retention period (and with no legal hold) the retention purge deletes the stored objects of every document of the application, sealed copies included, and marks the rows objectPurgedAt; the rows stay. The database allows that change to a sealed document only then. A separate bucket-listing reconciler is not needed: a document row and its scan job are written before any byte is stored, a failed upload or generation is cleaned by document-cleanup, and removed files are purged with their application, so every object has a row until it is deleted.
Security scan#
Each adopted upload queues a document.scan job in the same transaction. The worker streams the stored object to ClamAV (clamd, INSTREAM over TCP) and publishes the scan receipt through the scan lifecycle that the download gate verifies; nothing else can mark a file clean.
| Outcome | Document | Download |
|---|---|---|
clamd answers OK | clean, receipt stored | 200 with a 60 second URL |
| clamd reports a signature (also an encrypted file, or one over the scan limits) | rejected | 422 document_rejected |
| clamd or storage unreachable, signatures older than 72 hours | stays pending, retried with backoff for about 6 hours, then the sweep queues it again; no scan attempt is used up | 409 document_pending_scan |
| a scan started but failed (bad reply, size limit, connection lost) three times | stays pending, job blocked, audit document.scan_blocked | 503 document_scan_unavailable; staff must act, there is no automatic reset |
Every minute a sweep closes scans whose worker died (the attempt is recorded as reconciled, the old scanner run is retired and a new one registered on the next scan), queues due scans that have no queue job, and fails upload reservations whose uploader vanished (lease expired 10 minutes ago). Every 15 minutes a cleanup deletes the stored object of failed uploads one hour after the failure, unless any other row points at it. Audit events (actor system, no file content): document.scan_clean, document.scan_rejected, document.scan_blocked, document.scan_reconciled, document.upload_abandoned, document.upload_cleaned. Settings: CLAMD_HOST, CLAMD_PORT, CLAMD_TIMEOUT_MS (see the deployment guide).
Upload and removal are audited as document.uploaded and document.deleted. Every download link issued is audited as document.accessed (who, application, type, version; for a reference link the reference id; staff reads are flagged). Refused reads leave no event. Removal keeps the stored object and sets removedAt.