Skip to main content

Uploading Files

The File Storage service provides two upload methods:

  • Multipart upload (POST /api/v1/files/upload) — the file is sent to the API server, which proxies it to S3. Simple, suitable for small files and server-to-server uploads.
  • Presigned upload URL (POST /api/v1/files/presign) — the client uploads directly to S3 using a short-lived signed URL. No file bytes pass through the API server. Recommended for large files and browser uploads.

Both methods go through the same staging + AV scan pipeline before the file becomes accessible.


Method 1 — Multipart Upload

SDK

import { kernel } from '@platform/sdk-core';

const result = await kernel.files().upload({
file: fileBuffer, // Buffer | Blob | ReadableStream
filename: 'contract-2026.pdf',
mimeType: 'application/pdf',
bucket: 'documents',
metadata: { contractId: '01j9pacon100000000000000' },
});

// result:
// {
// fileId: '01j9paf1l000000000000000',
// filename: 'contract-2026.pdf',
// mimeType: 'application/pdf',
// size: 204800,
// status: 'pending_scan',
// createdAt: '2026-04-15T10:30:00.000Z',
// uploadedBy: '01j9pa5mz700000000000000'
// }

REST

POST https://api.septemcore.com/v1/files/upload
Authorization: Bearer <access_token>
Content-Type: multipart/form-data

--boundary
Content-Disposition: form-data; name="file"; filename="contract-2026.pdf"
Content-Type: application/pdf

<binary content>
--boundary
Content-Disposition: form-data; name="bucket"

documents
--boundary
Content-Disposition: form-data; name="metadata"

{"contractId":"01j9pacon100000000000000"}
--boundary--

Response 201 Created:

{
"fileId": "01j9paf1l000000000000000",
"filename": "contract-2026.pdf",
"mimeType": "application/pdf",
"size": 204800,
"bucket": "documents",
"status": "pending_scan",
"url": null,
"createdAt": "2026-04-15T10:30:00.000Z",
"uploadedBy": "01j9pa5mz700000000000000"
}

url is null while status is pending_scan. It is populated once the AV scan completes and the file moves to available.


Method 2 — Presigned Upload URL

Use this method for browser-based uploads or large files. The API server issues a short-lived signed URL; the client uploads directly to S3. No binary data passes through the API server.

Step 1 — Request a presigned URL

POST https://api.septemcore.com/v1/files/presign
Authorization: Bearer <access_token>
Content-Type: application/json

{
"filename": "hero-image.png",
"mimeType": "image/png",
"bucket": "assets",
"size": 1048576
}

Response 200 OK:

{
"fileId": "01j9paf2m000000000000000",
"uploadUrl": "https://s3.septemcore.com/staging/01j9p3kz.../01j9paf2m...?X-Amz-Signature=...",
"method": "PUT",
"expiresAt": "2026-04-15T10:45:00.000Z",
"fields": {},
"status": "pending_scan"
}

The uploadUrl is valid for 15 minutes (FILES_PRESIGN_EXPIRY_SEC=900).

Step 2 — Upload directly to S3

PUT <uploadUrl>
Content-Type: image/png

<binary content>

Response from S3: 200 OK (no body). The file is now in the staging bucket with status pending_scan.

POST https://api.septemcore.com/v1/files/presign/01j9paf2m000000000000000/confirm
Authorization: Bearer <access_token>
{
"fileId": "01j9paf2m000000000000000",
"status": "pending_scan"
}

Confirmation triggers immediate AV scan queue prioritisation instead of waiting for the S3 Event Notification (which can take 1–2 seconds).

SDK (presigned flow)

const { fileId, uploadUrl } = await kernel.files().presign({
filename: 'hero-image.png',
mimeType: 'image/png',
bucket: 'assets',
size: 1_048_576,
});

// Upload directly to S3 (fetch or XMLHttpRequest)
await fetch(uploadUrl, {
method: 'PUT',
body: fileBlob,
headers: { 'Content-Type': 'image/png' },
});

// Poll or wait for AV scan to complete
const file = await kernel.files().waitForScan(fileId, { timeoutMs: 30_000 });
// file.status === 'available'

Staging → AV Scan → Available

After upload (both methods), the file enters the AV scan pipeline:

Upload complete → S3 staging bucket: {tenantId}/staging/{fileId}
File status: pending_scan
File NOT accessible via GET /files/:id

S3 Event Notification → File Service → AV scanner
┌─ CLEAN:
│ move → main bucket: {tenantId}/{bucket}/{fileId}
│ status: available
│ url: https://api.septemcore.com/v1/files/{fileId}

└─ INFECTED:
delete from staging
status: rejected
POST notify.notification.sent → tenant admin

Polling File Status

const file = await kernel.files().metadata('01j9paf1l000000000000000');
// { fileId, status: 'pending_scan' | 'available' | 'rejected', url, ... }
GET https://api.septemcore.com/v1/files/01j9paf1l000000000000000
Authorization: Bearer <access_token>
{
"fileId": "01j9paf1l000000000000000",
"status": "available",
"filename": "contract-2026.pdf",
"mimeType": "application/pdf",
"size": 204800,
"bucket": "documents",
"url": "https://api.septemcore.com/v1/files/01j9paf1l000000000000000",
"createdAt": "2026-04-15T10:30:00.000Z"
}

Staging Limits

LimitValueEnv variableOn exceed
Staging file TTL24 hoursFILES_STAGING_TTL_HOURS=24Auto-rejected, uploader notified
Max pending files per tenant100FILES_STAGING_MAX_PER_TENANT=100503 Service Unavailable
AV scan queueFIFO normallyIf backlog > 1 000 → newest first

A file stuck in pending_scan for more than 24 hours is automatically moved to rejected and the upload user receives a notification. This prevents staging storage from accumulating abandoned uploads.


Upload Parameters

ParameterRequiredDescription
file / binary bodyFile content (multipart or S3 PUT)
filenameOriginal filename with extension
mimeTypeMIME type (e.g. image/jpeg, application/pdf)
bucketTarget logical bucket: avatars, assets, documents, exports, modules
sizeRequired for presignFile size in bytes (used to set Content-Length)
metadataArbitrary key-value object stored alongside the file record

Error Reference

ScenarioHTTPCode
File infected (AV scan)Status rejectedCheck via GET /files/:id
Staging limit exceeded (100 pending)503STAGING_LIMIT_EXCEEDED
File exceeds 10 MB (images)413PAYLOAD_TOO_LARGE
Invalid bucket name400validation-error
Presigned URL expired403S3 returns RequestExpired
mimeType mismatch vs actual file422MIME_TYPE_MISMATCH