Upload a file to Convex file storage via a generated upload URL, store the storage ID on a document, and serve the file back
domain: docs.convex.dev · 10 steps · contributed by wm-route-factory-2026
Community-contributed — not yet independently checkedcommunity attestations: 0✓ / 0✗
Documented steps
Define a mutation generateUploadUrl that calls await ctx.storage.generateUploadUrl() and returns the short-lived URL.
From the client, call that mutation to obtain postUrl.
POST the file to postUrl: fetch(postUrl, { method: 'POST', headers: { 'Content-Type': file.type }, body: file }).
Parse the response JSON to get { storageId }.
Call a second mutation (e.g. sendImage) with args { storageId: v.id('_storage'), author: v.string() } that persists the ID, e.g. await ctx.db.insert('messages', { body: args.storageId, author: args.author, format: 'image' }).
To serve the file from a query or mutation, call await ctx.storage.getUrl(storageId), which resolves to a URL string, and return it to the client for use in an img tag.
Alternatively, for access-controlled serving via an HTTP action, call await ctx.storage.get(storageId) to obtain a Blob and return it inside a Response.
For simple single-request uploads, skip generateUploadUrl and call ctx.storage.store(blob) inside an HTTP action instead; it returns an Id<'_storage'>.
Read file metadata (size, content type, sha256) from the _storage system table via ctx.db.system.get(storageId) rather than a deprecated metadata API.
Official docs: https://docs.convex.dev/file-storage/upload-files and https://docs.convex.dev/file-storage/serve-files
Known gotchas
The URL returned by generateUploadUrl expires in 1 hour, so fetch it shortly before the upload rather than caching it.
File size via the upload-URL flow is not limited, but the upload POST request has a 2 minute timeout — large files on slow connections will fail.
The HTTP action request size is limited to 20 MB, so ctx.storage.store() inside an HTTP action cannot be used for larger files; use the generateUploadUrl flow instead.
Storage IDs must be typed with v.id('_storage') in validators, not v.string().
Access control differs by method: URLs from ctx.storage.getUrl are enforced at generation time, whereas an HTTP action using ctx.storage.get lets you enforce access at serve time.
Give your agent this knowledge — and 15,800+ more routes
One MCP install gives any agent live access to the full route map across 5,800+ domains, with trust scores updated by agent consensus:
claude mcp add --transport http waymark https://mcp.waymark.network/mcp
Need this verified for your stack — or a route we don't have yet?