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 pathWhoWhat
POST /applications/:id/documentsapplicant who owns it, supplier with documents.upload in that organization, or staff; only while the application has no final decisionmultipart upload
GET /documents/:idanyone allowed to read it (below){ url, name }
DELETE /documents/:idthe uploader, for their own unattached document, before a final decisionsoft remove
POST /documents/:id/reviewsupplier of that application with documents.review, never on their own uploadaccept, reject or ask for more
GET /documents/requirementssupplier with documents.viewthe seven requirable types and their setting
PUT /documents/requirements/:typesupplier with documents.requirements.manageset never, optional or required for a type
POST /reference/:token/documentspublic, reference link tokenupload evidence for that reference
GET /reference/:token/documents/:idpublic, link tokendownload 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.

StatusWhen
403no 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)
404unknown or unreadable document, or an invalid reference link
409download: the file is still pending its security scan (document_pending_scan), or it changed while signing the link
410the reference link expired
422download: the scanner rejected the file (document_rejected); upload a replacement
503download: the scan could not be completed (document_scan_unavailable); contact support
429upload limit
503storage 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? }:

OutcomeStatusNotes
acceptedacceptedfor an insurance certificate expiresAt (a future date) starts the validity clock
rejected, needs_morerejected, needs_morereasonCode 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.

OutcomeDocumentDownload
clamd answers OKclean, receipt stored200 with a 60 second URL
clamd reports a signature (also an encrypted file, or one over the scan limits)rejected422 document_rejected
clamd or storage unreachable, signatures older than 72 hoursstays pending, retried with backoff for about 6 hours, then the sweep queues it again; no scan attempt is used up409 document_pending_scan
a scan started but failed (bad reply, size limit, connection lost) three timesstays pending, job blocked, audit document.scan_blocked503 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.