Downloading Files
The File Storage service provides two ways to retrieve a file:
- Direct download (
GET /api/v1/files/:id) — the API server streams the binary content to the client. Simple, works everywhere. - Presigned download URL (
GET /api/v1/files/:id/url) — the server returns a short-lived signed URL pointing directly to S3. The client fetches the file bypassing the API server. Recommended for large files, CDN integration, and browser<img src>tags.
Both methods perform a JWT tenantId check before serving any bytes.
Method 1 — Direct Download
SDK
const stream = await kernel.files().download('01j9paf1l000000000000000');
// Returns a ReadableStream — pipe to response or save to disk
REST
GET https://api.septemcore.com/v1/files/01j9paf1l000000000000000
Authorization: Bearer <access_token>
Response 200 OK:
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 204800
Content-Disposition: attachment; filename="contract-2026.pdf"
Cache-Control: private, no-cache
ETag: "a1b2c3d4e5f6..."
<binary content>
The Content-Disposition header defaults to attachment (triggers
browser download). To display inline (e.g. for PDF preview in browser):
GET https://api.septemcore.com/v1/files/01j9paf1l000000000000000?inline=true
Authorization: Bearer <access_token>
Returns Content-Disposition: inline; filename="contract-2026.pdf".
Method 2 — Presigned Download URL
Returns a time-limited URL that bypasses the API server. The client fetches the file directly from S3. Use this for:
- Browser
<img src>,<video src>,<a href>tags - CDN origin URLs
- Sharing files with third-party services without exposing the JWT
SDK — Presigned Download
const { url, expiresAt } = await kernel.files().url(
'01j9paf1l000000000000000',
{ expirySeconds: 3600 } // optional, default 3600 (1 hour)
);
// url: 'https://s3.septemcore.com/tenantId/documents/01j9paf1l...?X-Amz-Signature=...'
// expiresAt: '2026-04-15T11:30:00.000Z'
REST — Presigned Download
GET https://api.septemcore.com/v1/files/01j9paf1l000000000000000/url?expiry=3600
Authorization: Bearer <access_token>
Response 200 OK:
{
"fileId": "01j9paf1l000000000000000",
"url": "https://s3.septemcore.com/01j9p3kz.../documents/01j9paf1l...?X-Amz-Signature=abc&X-Amz-Expires=3600",
"expiresAt": "2026-04-15T11:30:00.000Z",
"method": "GET"
}
The presigned URL is valid for the requested expiry seconds (default
3 600, max 86 400 — 24 hours). After expiry, S3 returns 403 Forbidden.
Using in a browser component
// In a React component
const { url } = await kernel.files().url(fileId, { expirySeconds: 3600 });
return <img src={url} alt={filename} />;
Tenant Isolation
File ownership is verified on every request. The File Service:
- Extracts
tenantIdfrom the JWT (injected by API Gateway). - Looks up the file record in PostgreSQL.
- Compares the record's
tenantIdagainst the JWT'stenantId. - If they differ →
403 Forbidden— same response as "not found" to prevent tenant enumeration.
Tenant A requests file owned by Tenant B:
GET /files/01j9paf1b00000000000000
Authorization: Bearer <tenant-A-JWT>
→ File Service: tenantId(JWT) = 'tenant-A'
tenantId(file) = 'tenant-B'
→ 403 Forbidden (not 404 — no enumeration)
S3 key prefixes provide a second layer: even if JWT validation were bypassed (it cannot be), the S3 IAM role only grants the service access to its own tenant prefix.
File Metadata (without download)
To retrieve file metadata without downloading the binary:
GET https://api.septemcore.com/v1/files/01j9paf1l000000000000000
Authorization: Bearer <access_token>
Accept: application/json
When the Accept: application/json header is present, the service
returns metadata instead of the binary stream:
{
"fileId": "01j9paf1l000000000000000",
"filename": "contract-2026.pdf",
"mimeType": "application/pdf",
"size": 204800,
"bucket": "documents",
"status": "available",
"url": "https://api.septemcore.com/v1/files/01j9paf1l000000000000000",
"createdAt": "2026-04-15T10:30:00.000Z",
"uploadedBy": "01j9pa5mz700000000000000",
"metadata": { "contractId": "01j9pacon100000000000000" }
}
List Files
GET https://api.septemcore.com/v1/files?bucket=documents&limit=20&cursor=01j9paf...
Authorization: Bearer <access_token>
{
"data": [
{
"fileId": "01j9paf1l000000000000000",
"filename": "contract-2026.pdf",
"mimeType": "application/pdf",
"size": 204800,
"bucket": "documents",
"status": "available",
"createdAt": "2026-04-15T10:30:00.000Z"
}
],
"pagination": {
"nextCursor": null,
"hasMore": false
}
}
Filter parameters:
| Parameter | Description |
|---|---|
bucket | Filter by logical bucket (avatars, assets, documents, exports, modules) |
status | Filter by status: available, pending_scan, rejected, deleted |
mimeType | Filter by MIME type prefix (e.g. image/) |
limit | Page size (default 20, max 100) |
cursor | Cursor from previous page's nextCursor |
Presigned URL Expiry Reference
| Use case | Recommended expiry |
|---|---|
<img src> in a web page (re-rendered regularly) | 3 600 s (1 hour) |
| One-time download link sent by email | 86 400 s (24 hours, maximum) |
| CDN origin URL | 3 600 s — CDN caches the response, not the URL |
| Server-to-server file transfer | 300 s (5 minutes) |
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
| File not found | 404 | not-found |
| File belongs to different tenant | 403 | forbidden |
File status is pending_scan | 404 | FILE_NOT_AVAILABLE (status shown in metadata) |
File status is rejected | 410 | FILE_REJECTED |
File status is deleted | 410 | FILE_DELETED |
| Presigned URL expired | 403 | S3: AccessDenied — RequestExpired |
expiry > 86 400 | 400 | validation-error |