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.
Step 3 — Confirm upload (optional but recommended)
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
| Limit | Value | Env variable | On exceed |
|---|---|---|---|
| Staging file TTL | 24 hours | FILES_STAGING_TTL_HOURS=24 | Auto-rejected, uploader notified |
| Max pending files per tenant | 100 | FILES_STAGING_MAX_PER_TENANT=100 | 503 Service Unavailable |
| AV scan queue | FIFO normally | — | If 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
| Parameter | Required | Description |
|---|---|---|
file / binary body | ✅ | File content (multipart or S3 PUT) |
filename | ✅ | Original filename with extension |
mimeType | ✅ | MIME type (e.g. image/jpeg, application/pdf) |
bucket | ✅ | Target logical bucket: avatars, assets, documents, exports, modules |
size | Required for presign | File size in bytes (used to set Content-Length) |
metadata | ☐ | Arbitrary key-value object stored alongside the file record |
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
| File infected (AV scan) | Status rejected | Check via GET /files/:id |
| Staging limit exceeded (100 pending) | 503 | STAGING_LIMIT_EXCEEDED |
| File exceeds 10 MB (images) | 413 | PAYLOAD_TOO_LARGE |
| Invalid bucket name | 400 | validation-error |
| Presigned URL expired | 403 | S3 returns RequestExpired |
mimeType mismatch vs actual file | 422 | MIME_TYPE_MISMATCH |