Files
Upload, sealed and public files, attachments in a group chat, and what happens if.
Files
const file = await tasks.upload(imageBlob, { name: "avatar.png" })
// { id, ref, contentType, sizeBytes } — store file.ref in a record field
const { url, expiresAt } = await g.files.link(file.ref)
// the only way a file becomes a URL, checked against the safety rule every time
upload() is for the files people upload:
- It accepts images (JPEG, PNG, WebP, GIF, HEIC) and documents (PDF, ZIP, EPUB), up to 25 MB per file.
- It accepts audio (mp3, m4a, wav, webm, ogg, flac, aac) up to 100 MB and video (mp4, webm, mov) up to 500 MB, in every collection. In Development the caps are 25 MB and 50 MB.
- Nothing else is accepted: no SVG or HTML (zip an office file).
- The server checks the file's actual bytes, not its name, and never converts them: no transcoding, no thumbnails.
- Audio and video play in the page: put
g.files.link(ref)'s URL in<audio>or<video controls>. They stream and seek, and are private like every file unless the collection is one anyone can read. - Per person: 20 audio or video uploads an hour; 5 uploads of any file in progress (started, not confirmed), holding at most 1 GB; and a storage share of the larger of 10% of the app's storage or 2 GB.
- A document always downloads: it is served as an attachment and never opens
inside your page. Link it with
{ intent: "download" }.
A reference is not a URL. Files in collections anyone can read without signing in resolve to permanent CDN URLs on your app's own subdomain. Everything else resolves to a short-lived signed link, checked against the collection's safety rule every time a link is created.
On addressed and direct collections,
upload(blob, { for: userId }) hands the file to that one person. The
recipient is stamped at upload and never changes, and only they (and you) can ever
resolve it. That's how a direct message carries an attachment, and how an app hands
one client a document.
Attachments in a group chat
What it is. One file every member of a group chat can open. A group message names up to 20 people; its attachment names the same people.
What it does.
- Names the group. On a
directcollection,upload(blob, { for: [id, id, …] })takes up to 20 people, the same listcreate(data, { for: [ids] })takes. - Opens for each of them. Every person named, and the uploader, can
link()it and put its ref in their own messages. Everyone else is refused, as if the file did not exist. - Counts once. It is one file, held against the uploader's storage share.
What it does not do.
- Change who can open it. The list is stamped at upload; to add someone, upload it again with the new list.
- Take a list anywhere but
direct. Anaddressedfile names one person.
When you need something else.
- More than 20 people: upload into a
sharedcollection, which every signed-in person can read.
Example. A photo in a group chat of three.
const group = [ben.id, cleo.id]
const photo = await g.collection("chats").upload(blob, { for: group })
await g.collection("chats").create({ text: "look", photo: photo.ref }, { for: group })
// Ben and Cleo each open it with g.files.link(record.data.photo)
A field that holds file references becomes a file field, image or document,
from the files uploaded to it. File references are checked on every safety rule, locked
or not: a made-up ref is refused (unknown_file). Once a
live collection's fields are fixed, attaching the wrong kind is
refused too (invalid_shape, whose message states which kind the field takes).
Until then, the kind you upload sets the field's kind.
AI runs have their own file limits:
- An AI run's own output can be image, audio or video, up to 200 MB each, because the platform writes those files itself.
- A transcribe tool's audio input is one of: audio the person uploads to that tool with
g.ai.upload(tool, file), a sealed file ref they already own, or an https URL. g.ai.uploadtakes mp3, m4a, wav, webm, ogg and flac, at 25 MB per file (14 MB on Google). The upload is a sealed file owned by the person who uploaded it, and is deleted after 24 h if no run used it.
What happens if…
| If… | What happens | What you do |
|---|---|---|
| Someone uploads to a collection they can't write to | The upload is refused, exactly as a record write there would be. Upload permission is the collection's write rule | Nothing |
| Your human makes a collection private or locks it to a plan | Its files become private: their public links stop opening, and link() hands short-lived signed links | Store the file reference, never the URL |
| Development rule changes that move files pass 5 a day, or 2 GiB of files a day, for one app | A change that makes files public is 429 development_capped and the rule stays as it was; making a collection more private is never refused, and Live is never refused | Try again after resetAt, or test with npx gemmein dev |
| The file is SVG, HTML, an office file, or an audio or video format not listed (avi, mkv) | 415 unsupported_file_type. An empty file is 400 invalid_file | Zip an office file; convert media to mp4, webm, mov or a listed audio type |
| An image or document is over 25 MB | 413 file_too_large, and the message states the cap | Nothing |
| Audio is over 100 MB or video over 500 MB (25 MB and 50 MB in Development) | 413 file_too_large, naming the type and the environment. A file declared small but uploaded big is refused at confirm and discarded | Nothing |
| One person starts more than 20 audio or video uploads in an hour | 429 media_rate_limited with resetAt. Images, documents and other people are unaffected | Try again after resetAt |
| You have too many uploads in progress: one person with 5 files of any type started and not confirmed, or 1 GB of them | The next is 429 too_many_open_uploads | Confirm one, or wait an hour for them to expire |
| The bytes sent are larger than the size the upload declared | The upload itself is refused: the link admits only the declared size. Confirming it is 400 upload_incomplete | Declare the real size |
| One person's files would pass their share: the larger of 10% of the app's storage or 2 GB, counting uploads in progress | 429 person_storage_full. Other people keep uploading | Delete some of that person's files |
| Development passes 50 audio or video uploads in 24 hours for one app | 429 development_capped with resetAt. Live has no daily count | Try again after resetAt, or test with npx gemmein dev |
| An upload is never confirmed | It holds its declared size against that person's share, never the app's storage, for one hour, then lets go. Confirming it later is 400 upload_incomplete. The bytes are never served and are deleted within a day | Upload the file again |
| A Blob you built from bytes has no type | 415, and the message says none was declared | Pass { contentType } to upload() |
| The bytes are not the type the file claims (a renamed file, such as a picture called .mp4) | 400 invalid_file_content. The upload is discarded and never served or played | Send the real file; a retry gets the same answer |
| A record field holds a made-up ref, or another app's | 400 unknown_file on create and update | Store only refs that upload() returned |
| You link a file in a collection anyone can read | A permanent URL on your app's own subdomain | Nothing |
| You link any other file | A signed link that works for 2 minutes, or 5 minutes with { intent: "download" } | Call link() when you render; never store the URL |
| The person may not see the file | link() is refused. A file that isn't theirs answers 404, as if it did not exist. A missing plan or product answers 403 entitlement_required with the plan's key | Show a sign-in or upgrade prompt |
| Access is revoked after a link was issued | The next link() is refused. A link already issued works until it expires, and a copy already downloaded stays downloaded | Nothing |
A file is uploaded with { for: userId } | That person and the uploader can link it; everyone else is refused. for works on addressed and direct collections only | Nothing |
A file on a direct collection is uploaded with { for: [id, id, …] } | Every person named and the uploader can link it, and put its ref in a record; everyone else is refused | Nothing |
| The list names more than 20 people | 400 too_many_recipients, and nothing is uploaded | Upload into a shared collection |
A list is sent to an addressed, private or other collection that is not direct, or the list is empty | 400 invalid_recipients | Name one person, or upload into a direct collection |
| Someone on the list is not a person of this app, or is suspended | 400 invalid_audience, and nothing is uploaded | Fix the list |
| The collection is reply-only and someone on the list (or the one person named) has not written to the uploader first | 403 reply_only, the same answer the message gets, and nothing is uploaded | Wait for them to write first |
| Messages are turned off for the collection | 403 sends_disabled | Nothing |
| One member of the group deletes their account | They are removed from the file's list. The uploader and the other members keep it | Nothing |
| The uploader deletes their account | The file is deleted for everyone, as all their files are | Nothing |
| Your app's stored files reach 80% and then 100% of its storage | You get one email at each. On Live, uploads keep working for the lesser of 10% over or 7 days past it (in Development, up to twice the cap), checked with each file's actual size at confirm, then are refused with 429 usage_limit_exceeded | Add a 50 GB storage pack or move the app up a tier on Usage & billing, or delete files |
Thanks for telling us.
Thanks for telling us.
Last updated