File uploads for deployed apps use presigned upload URLs to Gipity Storage. The client uploads directly to storage (no proxy through the server), supporting files up to 30GB with progress tracking.
How It Works
- Init - App calls
POST /api/<APP_GUID>/uploads/initwith file metadata. Server returns a presigned upload URL. - Upload - Client PUTs the file directly to the presigned URL. No auth header needed - the URL is pre-authorized.
- Complete - App calls
POST /api/<APP_GUID>/uploads/complete. Server copies the file to permanent Gipity Storage, records it, and generates variants (thumbnails for images, text for PDFs, ID3 tags + cover-art thumbnail for audio). For audio it also returns the parsed tags ondata.audio.
Client Helper Library
The client SDK (gipity.js, auto-injected into every template via {{ANALYTICS_SCRIPT}}) exposes Gipity.upload for automatic progress tracking, multipart handling, and retries. No extra script tag is needed.
const result = await Gipity.upload(fileInput.files[0], {
onProgress: (pct) => progressBar.style.width = pct + '%',
public: false,
table: 'attachments',
recordId: 'rec_123',
});
console.log(result.guid, result.url);
The SDK auto-supplies appGuid from the script tag's data-app attribute and appToken from Gipity.auth (when the user is signed in). Pass them explicitly via { appGuid, appToken } only when calling from a page that doesn't load the SDK with data-app set.
Single-part uploads (< 5GB) and multipart uploads (5GB+) are handled transparently. Failed parts retry up to 3 times with exponential backoff.
Folder & multi-file uploads
Gipity.upload takes one File. For a folder drop or a multi-file picker, the browser makes you do fiddly, easy-to-get-wrong traversal first: a dropped folder leaves dataTransfer.files empty (or a zero-byte stub), so you must read the non-standard entry tree - capturing webkitGetAsEntry() synchronously in the drop handler (the items list goes stale the instant it returns) and looping readEntries(), which pages at 100 entries per call. The SDK owns that for you.
Gipity.collectFiles(source) normalizes a drop event, a DataTransfer, an <input webkitdirectory>, a plain <input multiple>, a FileList, or a File[] into a flat list. Each entry is { file, relativePath }, where relativePath is the path within the drop ("Album/Disc 1/track01.mp3") - just the filename for a loose file. It's traversal only: loop and call Gipity.upload yourself, or hand the list to uploadAll.
// In the drop handler - call collectFiles SYNCHRONOUSLY (don't await anything first).
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
const items = await Gipity.collectFiles(e); // [{ file, relativePath }, ...]
for (const { file, relativePath } of items) {
await Gipity.upload(file, { path: relativePath.split('/').slice(0, -1).join('/') });
}
});
// Or from a folder picker:
// <input type="file" webkitdirectory multiple>
input.addEventListener('change', () => Gipity.collectFiles(input).then(...));
Gipity.uploadAll(items, opts?) uploads a whole batch and mirrors the folder structure for you - each file's relativePath directory is appended to opts.path, so the tree lands the same way in storage. It accepts the array from collectFiles, or a raw drop/input it collects for you. Uploads run opts.concurrency at a time (default 3) and one file failing does not abort the batch - you get a per-item report. Pass { stopOnError: true } to reject on the first failure instead.
const report = await Gipity.uploadAll(e, { // pass the drop event directly
path: 'locker', // base path; folders nest under it
concurrency: 4,
onProgress: (overall, info) => bar.style.width = overall + '%', // 0-100 across the whole batch
});
// report.succeeded, report.failed, report.results: [{ relativePath, file, ok, value?, error? }, ...]
const failures = report.results.filter((r) => !r.ok);
opts also forwards appGuid, appToken, apiKey, public, table, recordId, and signal to each Gipity.upload call.
Client SDK surface
Browser globals from gipity.js: Gipity.upload, Gipity.collectFiles, Gipity.uploadAll, Gipity.fileUrl, Gipity.fn, Gipity.auth, Gipity.captureError, Gipity.version. upload stores one file; collectFiles flattens a folder drop / directory picker into { file, relativePath }[]; uploadAll uploads such a batch (mirroring folders under a base path); fileUrl gets an embeddable URL to display one (see Displaying files). To list or delete files, call your own auth: user functions via Gipity.fn (see Deleting a file). uploads/init always requires an authenticated user; anonymous init returns 401 UNAUTHORIZED, so personal-file apps need sign-in.
Endpoints
POST /api//uploads/init
Get a presigned upload URL.
Auth: App token (X-App-Token), API key (X-Api-Key), or session cookie.
Request:
{
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size": 2048576,
"public": false,
"table": "incidents",
"record_id": "42",
"path": "uploads"
}
filename(required) - original filenamecontent_type(required) - MIME typesize(required) - file size in bytes (max 30GB)public(optional, default false) - public CDN URL or signed URLtable(optional) - associate with a record tablerecord_id(optional) - associate with a specific recordpath(optional, default "uploads") - virtual directory path (no leading slash; any leading/duplicate slashes are normalized away)
Response (single-part, < 5GB):
{
"data": {
"upload_guid": "fl_Xk7mNp2R",
"method": "PUT",
"url": "https://muda.gipity.ai.s3...",
"headers": { "Content-Type": "image/jpeg" },
"expires_in": 600
}
}
Response (multipart, >= 5GB):
{
"data": {
"upload_guid": "fl_Xk7mNp2R",
"method": "multipart",
"upload_id": "abc123...",
"part_size": 104857600,
"parts": [
{ "partNumber": 1, "url": "https://..." },
{ "partNumber": 2, "url": "https://..." }
],
"expires_in": 600
}
}
POST /api//uploads/complete
Finalize the upload after the client has PUT the file to S3.
Request:
{
"upload_guid": "fl_Xk7mNp2R",
"parts": [
{ "part_number": 1, "etag": "\"abc123\"" },
{ "part_number": 2, "etag": "\"def456\"" }
]
}
upload_guid(required) - from the init responseparts(required for multipart only) - part numbers and ETags from each S3 PUT response
Response:
{
"data": {
"guid": "fl_Xk7mNp2R",
"name": "photo.jpg",
"size": 2048576,
"content_type": "image/jpeg",
"width": 1920,
"height": 1080,
"audio": null,
"url": "https://media.gipity.ai/app-files/...",
"is_public": false,
"table": "incidents",
"record_id": "42",
"variants": [
{ "type": "thumbnail", "url": "...", "content_type": "image/jpeg", "status": "complete" }
]
}
}
GET /api//files
List uploaded files. Supports filtering.
Query params: table, record_id, path, limit (default 50, max 200), offset
GET /api//files/:guid
Get file metadata + variant list.
GET /api//files/:guid/content
Download file (auth-gated). 302-redirects to the underlying storage. Convenience endpoint; for an embeddable/persistable URL use the durable serve URL below.
GET /api//files/:guid/serve?t=
The durable, embeddable access URL for a file. Auth is the capability token in ?t=, not a header — so it drops straight into <audio src>, <a download>, or an anonymous share page, and resolves for signed-out viewers. The server re-signs storage on every hit and 302-redirects (the browser follows it with its Range header, so audio/video seeking works), so the URL never expires on its own. You don't build this URL by hand — Gipity.fileUrl (client) and storage.fileUrl (functions) return it. Add &variant=thumbnail for a variant, &download=1 to force a download.
Serve URLs work everywhere: element sources (<img>, <audio>, <video>, <a download>), navigations, cross-origin fetch()/XHR from page JS (CORS-enabled — blob downloads, hashing, canvas export all work), and curl/curl -I.
GET /api//files/:guid/variants/:type
Get a specific variant (e.g., thumbnail), auth-gated. Redirects to storage.
Displaying files (<img src>, <audio>, downloads)
The url you get back is durable and safe to persist — store it on your record, in a share link, or render it straight into the page. It does not expire on its own and never exposes a storage hostname:
- public files → a permanent CDN URL (
media.gipity.ai/...) - private files → a branded serve URL that re-signs storage on every request
The metadata/content endpoints are auth-gated (
X-App-Token), so a bare<img src="https://a.gipity.ai/api/<APP_GUID>/files/<guid>/content">(cookie only, no header) is rejected. That is the one wrong way to show a private file — use the durableurl/Gipity.fileUrlinstead.
img.src = await Gipity.fileUrl(fileGuid); // full file
thumb.src = await Gipity.fileUrl(fileGuid, { variant: 'thumbnail' }); // 200x200 image thumb
For public: true files, Gipity.upload(...) already returns a usable result.url. Gipity.fileUrl is for private files or a variant URL.
Don't persist the raw upload result for a private file and assume it streams forever — persist the file guid, the durable url, or both. (The older pattern handed back a 1-hour signed storage URL; apps that stored that served it stale once it expired — the "Request has expired" bug. The serve URL above replaces it. If you have old rows holding an s3.amazonaws.com or ...X-Amz-... URL, re-resolve them from the guid via Gipity.fileUrl / storage.fileUrl.)
Serving private files to anonymous viewers (share links)
A public-auth function (e.g. a share-link or delivery resolver) can mint a durable, header-free URL for a privately-stored file with the storage.fileUrl helper — no sign-in needed by the viewer:
// functions/share-resolve.js (auth: public)
export default async function (ctx, { db, storage }) {
const link = await db.findOne('share_links', { short_name: ctx.query.s });
if (!link) return { error: 'not found' };
const file = await db.findOne('files', { short_guid: link.file_guid });
return {
name: file.filename,
url: await storage.fileUrl(file.file_guid), // streamable, durable
download_url: await storage.fileUrl(file.file_guid, { download: true }),
};
}
The returned URL is permanent until you delete the file or call storage.revokeFileUrl(fileGuid) (which bumps a per-file epoch and invalidates every URL previously minted for it — call storage.fileUrl again for a fresh one). Store the file guid, never a baked-in signed URL.
Testing it: to assert a function resolves a real serve URL, make a real file in the test with
ctx.upload(name, opts?)(returns{ guid, url, ... }) and pass itsguid— a fabricatedstorage_guidwon't resolve. See app-testing → Testing file serving.
Variants
Variants are generated synchronously at upload-complete for files up to 10MB - they're in the upload result's variants array immediately, or never (nothing materializes later):
- thumbnail - Images resized to 200x200px (JPEG), any dimensions. Audio files with embedded cover art (ID3 APIC) also get a
thumbnailvariant from that art. - text - PDFs have text extracted to plain text
A file can legitimately have no thumbnail - over 10MB, undecodable bytes, non-image, audio without cover art. When generation is skipped or fails, the upload result carries a warnings array saying why. So check result.variants before resolving: Gipity.fileUrl and storage.fileUrl with { variant: 'thumbnail' } throw when the variant doesn't exist - fall back to the original file or a placeholder in your grid/list rendering.
For audio uploads, the complete-response also carries a data.audio object with the parsed ID3/container tags (null when the file isn't audio or carries no readable tags):
"audio": {
"artist": "Daft Punk", "album": "Discovery", "genre": "House",
"title": "One More Time", "track_number": 1,
"track_seconds": 320, "duration_ms": 320373, "bitrate": 320, "year": 2001
}
Persist these onto your own record if you want a searchable/sortable music library — the platform returns them but does not store them on your app's tables for you.
Deleting a file
There is no client-side or HTTP delete - removing a file is a privileged operation, so it lives in function code. Every deployed function gets a storage helper in its services argument (always available, like db - no gipity.yaml declaration needed):
await storage.delete(fileGuid); // → { deleted: true } (or false if not found)
await storage.fileUrl(fileGuid, opts?); // → durable access URL (see "share links" above)
await storage.revokeFileUrl(fileGuid); // → { revoked: true } (invalidate minted URLs)
This permanently removes the stored bytes - the file and any thumbnail/text variants - from Gipity Storage, and marks the file deleted. It is scoped to your app (a function can only delete its own app's files) and idempotent: deleting an unknown or already-deleted guid returns { deleted: false } instead of throwing.
Delete the bytes whenever you delete the record that points at them - otherwise the file lingers in storage forever, leaking storage on every removal. The usual shape: the browser calls one of your functions, which removes both the record and the file.
// functions/delete-receipt.js
export default async function (ctx, { db, storage }) {
const { file_guid } = ctx.body;
const row = await db.findOne('receipts', { file_guid, user_guid: ctx.auth.userGuid });
if (!row) return { error: 'not found' };
await db.delete('receipts', { id: row.id }); // your app-data row
await storage.delete(file_guid); // the actual bytes - reclaims storage
return { ok: true };
}
// client - call your function; never try to delete files straight from the browser
await Gipity.fn('delete-receipt', { file_guid });
file_guid is the guid returned from the upload (Gipity.upload(...) → result.guid) - the same value you stored on your record.
Limits
- Max file size: 30GB
- Presigned URL expiry: 10 minutes
- Rate limit: 300 uploads per minute
- Multipart part size: 100MB
Manual Upload (without helper library)
If you can't use the helper library, implement the three-step flow directly:
// 1. Init
const init = await fetch(`https://a.gipity.ai/api/${appGuid}/uploads/init`, {
method: 'POST',
headers: { 'X-App-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
content_type: file.type,
size: file.size,
}),
}).then(r => r.json());
// 2. Upload to S3
await fetch(init.data.url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
// 3. Complete
const result = await fetch(`https://a.gipity.ai/api/${appGuid}/uploads/complete`, {
method: 'POST',
headers: { 'X-App-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_guid: init.data.upload_guid }),
}).then(r => r.json());
console.log(result.data.url); // permanent file URL
Integration with Records
Files can be associated with records via table and record_id fields (soft reference). Example flow:
- User uploads a screenshot with
table: 'incidents', record_id: '42' - Frontend lists attachments via
GET /api/:app/files?table=incidents&record_id=42 - Each attachment shows the thumbnail variant as a preview