standards

Files and documents

Upload files, serve them with signed URLs, and attach them to records through documents.

The platform separates raw storage from structure:

  • A file is an uploaded binary blob (a PDF, an image) with metadata — name, MIME type, size.
  • A document attaches files to a record. It's a title wrapping an ordered, anonymous pack of files (position ASC) — there are no named slots. The record's document attribute in the schema only constrains what can go into the pack: which MIME types are accepted and the max file size.

Upload files first, then attach them to records through documents — or do both in one call with record attach.

Uploading files

Send a multipart/form-data request to POST /files/upload with the binary parts in the files field:

curl https://api.standards.new/v1/files/upload \
  -H "Authorization: Bearer stndrds_your_api_key" \
  -F "[email protected]" \
  -F "folderPath=/contracts"

The response is an array of created files:

[
  {
    "id": "8a1c2d5f-4e6b-4a8e-9d2f-3c6b7e9a1c2d",
    "name": "contract.pdf",
    "originalName": "contract.pdf",
    "mimeType": "application/pdf",
    "size": 482133,
    "url": "https://…"
  }
]

Upload limits:

  • Up to 10 files per request, 50 MB each.
  • The platform checks the declared MIME type against the file's actual bytes (its magic-number signature). A mismatch is rejected with a 400.
  • Uploads are rate-limited to 20 requests per minute (see Rate limits).

Serving files

Files are private. To let a user download one, request a short-lived signed URL with GET /files/{id}/url. The response contains the temporary link:

{ "url": "https://storage.example.com/…?signature=…" }

Signed URLs expire after one hour. Request a fresh one each time you need it — a stored or cached URL starts returning errors once it expires.

Managing files

OperationEndpointNotes
ListGET /fileslimit, offset, folderPath, mimeType query params. Returns a plain array.
Batch fetchGET /files/resolve?ids=a,b,cResponse order is not guaranteed — match by id.
Get oneGET /files/{id}
UpdatePUT /files/{id}name and folderPath only.
DeleteDELETE /files/{id}Soft delete.
Bulk deletePOST /files/bulk-deletePartial success — returns { deleted, failed }.

File endpoints require the matching action on the files system resource.

Attaching in one call

POST /records/{objectName}/{id}/documents/attach is the fastest path: one multipart request uploads a single file (max 200 MB) and creates the document.

curl https://api.standards.new/v1/records/contacts/<recordId>/documents/attach \
  -H "Authorization: Bearer stndrds_your_api_key" \
  -F "[email protected]" \
  -F "attributeName=identityDocument" \
  -F "title=Passport"
  • attributeName — which document attribute of the record to attach under (default attachments).
  • documentId — attach into an existing document instead of creating one; the file is appended to its pack and the document's title is left untouched.

If document creation fails after the upload, the platform deletes the just-uploaded file automatically, so no orphaned files remain. The two steps are still not atomic — a brief window exists where the file is uploaded but not yet attached.

Attaching an existing file

When the file is already uploaded (or shared between documents), attach it by id with POST /documents/attach:

curl https://api.standards.new/v1/documents/attach \
  -H "Authorization: Bearer stndrds_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "objectName": "contacts",
    "attributeName": "identityDocument",
    "fileId": "8a1c2d5f-4e6b-4a8e-9d2f-3c6b7e9a1c2d"
  }'

A file belongs to exactly one document pack, and its first attach consumes it. Attaching a fileId that is already packed does not fail and does not duplicate the blob: the document that already holds it is linked to the target attribute instead, and the response reports action: "linked-existing" (against "created" for a new document and "appended" when you passed a documentId). Passing a documentId that is not the file's own pack is refused with DOCUMENT_FILE_ALREADY_PACKED, naming the document that holds it.

Linking only ever reaches a pack you can already write. A file packed on a private drive you cannot see stays reported as DOCUMENT_FILE_NOT_FOUND — indistinguishable from a file that does not exist, so a file id cannot be used to probe for documents.

When you already know the target document, two pack-level endpoints do the same job: POST /documents/{id}/files with { fileId } appends a file to the pack (its position is set to max(position) + 1, or 0 for an empty pack), and DELETE /documents/{id}/files/{fileId} removes one — the file survives as a blob and the document survives even if this empties the pack. The CLI's documents add-file <id> --file-id <fileId> and documents remove-file <id> <fileId> map to these.

DOCUMENT_MIME_NOT_ACCEPTED (see Errors) is raised when a file isn't allowed by the attribute's accepts list. It's an attribute-level check, not a per-file or per-slot one — there is no slot concept to enforce.

Each entry of accepts takes one of the three forms the HTML accept attribute allows, and a file passes if it matches any one of them:

FormExampleMatched against
MIME globimage/*the file's MIME type, by prefix
Exact MIME typeapplication/pdfthe file's MIME type
Extension.pdfthe uploaded file name, case-insensitively

An absent or empty accepts is unconstrained. The server applies this rule before every write, and the file pickers that declare an accept string apply the same one client-side, so a file those pickers offer is never rejected afterwards. Drag-and-drop onto the Drive has no client-side check — there the rejection arrives from the server.

Reading a record's documents

GET /records/{objectName}/{id}/documents returns the record's documents grouped by attribute: { byAttribute, total }.

For a single document:

Document lifecycle

OperationEndpointNotes
ListGET /documentslimit, offset. Returns a plain array.
Get oneGET /documents/{id}
CreatePOST /documentsRequires title, objectName, sourceRecordId, attributeName — a document always belongs to a record. kind can be "folder" to group documents into folders on the record.
RetitlePATCH /documents/{id}Changes the document's title, nothing else.
RenamePOST /documents/{id}/renameChanges the title, { name } in the body. See below.
Delete / restoreDELETE /documents/{id} / POST /documents/{id}/restoreSoft delete, like records.

PATCH /documents/{id} and POST /documents/{id}/rename both only update the document's title field — nothing else. rename does not touch any stored file: no file rename, no extension/storage-path handling, and no rollback logic, since there is nothing else to roll back.

Document endpoints require the matching action on the documents system resource — except record attach, which checks update on the record's object.

Uploading from the CLI

The standards CLI manages documents and pack files (add-file, remove-file, files, preview, content) but does not upload file bytes — upload via the API to get a fileId, then attach it. See the CLI guide.

Moving and copying across records

Each record owns a drive: the folder tree holding its documents. Two endpoints work on that tree — POST /folders/{id}/move relocates an item, POST /folders/{id}/copy duplicates it. Both take a document id or a folder id in the path; on a folder they apply to the whole subtree.

The attribute travels with the document

A document does not only sit on a drive: it can occupy a document() attribute — a contract, an idCard — with values filled on the record→document edge. Both endpoints carry that placement across.

Whenever the destination record's object declares a document attribute with the same name as the one the document currently occupies, the document is filed on it. Names are the only correspondence between two objects' attributes, so contract lands on contract and nowhere else. Plain drive membership (attachments) is the fallback, used only when the destination declares no attribute by that name — never a default. A document that was already a plain drive item stays one.

Qualified values (.qualifyWith(...)) survive field by field: a field crosses when both attributes declare it with the same name and the same type. A field the destination does not declare is dropped, and so is one whose type differs — a date value never lands in a text field.

A move also detaches the item from every record it leaves, attribute references included: the document is no longer on their drive, so a reference kept there would point at something the record can no longer reach. A copy changes nothing on the source, which keeps its attribute and its values.

Copying onto a record's drive

curl https://api.standards.new/v1/folders/<documentId>/copy \
  -H "Authorization: Bearer stndrds_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "toRecordId": "3f1e5c7a-9b2d-4e11-8c6f-1d4a7b0e2f93",
    "toParentId": null
  }'
  • toRecordIdrequired. The record whose drive receives the copy.
  • toParentId — the destination folder inside that drive. null or omitted means the drive root.

The response is the root document of the new copy: same title as the source, appended after the destination's existing items.

A copy is a full duplicate, never a second reference to the same data — new document rows, new file rows, and new storage blobs. Nothing is shared with the source, so deleting either side leaves the other intact. Extracted OCR text rides along with the copy instead of being recomputed, since the bytes are identical.

Copying a folder copies its whole subtree: sub-folders, their documents, and every file in each pack, keeping the source order inside the copy. A copy is all-or-nothing — a tree past either limit below is refused outright rather than copied in part.

The destination folder must be on the destination drive

toParentId is checked against toRecordId: a folder belonging to another record's drive is refused. Pass null to land at the destination record's drive root.

A copy validates everything before writing its first row, so a refused copy never leaves a half-built tree behind:

CaseResponse
Unknown document id in the path404 SCHEMA_RECORD_NOT_FOUNDDocument with id "…" not found
toRecordId missing from the body400 SCHEMA_VALIDATION_FAILEDtoRecordId is required to copy a document.
toParentId on another record's drive400 SCHEMA_VALIDATION_FAILEDDestination folder "…" is not on record "…"'s drive.
Subtree of more than 1000 documents400 SCHEMA_VALIDATION_FAILEDCannot copy "…": its subtree exceeds the 1000-item copy limit.
Subtree more than 32 folders deep400 SCHEMA_VALIDATION_FAILEDCannot copy "…": its subtree is deeper than the 32-level copy limit.

Split a larger tree into several copies — the limit is per call, and it exists so one request can never duplicate a whole workspace.

Moving between drives

curl https://api.standards.new/v1/folders/<documentId>/move \
  -H "Authorization: Bearer stndrds_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "toRecordId": "3f1e5c7a-9b2d-4e11-8c6f-1d4a7b0e2f93",
    "toParentId": null
  }'
FieldMeaning
toParentIdNew parent folder. null = drive root. Omit it to keep the current parent (a pure reorder).
positionOptional fractional position among siblings. Omitted = appended last.
toRecordIdThe record whose drive owns the item after the move. Omitted = the item stays on its current drive.

toRecordId is the only way across a drive boundary. Without it, a toParentId sitting on another record's drive is refused — the item would carry its whole file subtree into a record it was never filed under. Restating the record the item already belongs to is not a crossing and changes nothing.

When the move does cross, the item stops being reachable from its old drive: passing toParentId: null files it at the destination record's root, while a toParentId inside the destination drive lets it inherit that drive through its parent chain.

CaseResponse
Unknown document id in the path404Document with id "…" not found
Unknown toRecordId404Record with id "…" not found, raised before any write
toParentId on another drive, no toRecordId400Target folder is not on the same record drive as the document
toParentId not on toRecordId's drive400 — same containment rule, applied against the destination drive
toParentId is the folder itself or one of its descendants400Cannot move folder into itself / …into one of its descendants (cycle)
toParentId is a document, not a folder400Target must be a folder

A move never touches file rows or blobs: it re-parents the existing documents. Use copy when both records must end up with their own independent set of files.