Skip to main content

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:

  1. Extracts tenantId from the JWT (injected by API Gateway).
  2. Looks up the file record in PostgreSQL.
  3. Compares the record's tenantId against the JWT's tenantId.
  4. 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:

ParameterDescription
bucketFilter by logical bucket (avatars, assets, documents, exports, modules)
statusFilter by status: available, pending_scan, rejected, deleted
mimeTypeFilter by MIME type prefix (e.g. image/)
limitPage size (default 20, max 100)
cursorCursor from previous page's nextCursor

Presigned URL Expiry Reference

Use caseRecommended expiry
<img src> in a web page (re-rendered regularly)3 600 s (1 hour)
One-time download link sent by email86 400 s (24 hours, maximum)
CDN origin URL3 600 s — CDN caches the response, not the URL
Server-to-server file transfer300 s (5 minutes)

Error Reference

ScenarioHTTPCode
File not found404not-found
File belongs to different tenant403forbidden
File status is pending_scan404FILE_NOT_AVAILABLE (status shown in metadata)
File status is rejected410FILE_REJECTED
File status is deleted410FILE_DELETED
Presigned URL expired403S3: AccessDeniedRequestExpired
expiry > 86 400400validation-error