Write a file to storage from a script
Goal
Have a Custom API build a file — a CSV export, a JSON drop for another system — and put it somewhere an outside system can collect it.
Why this exists
The platform has three places a file can live, and they are not interchangeable.
| For | Example | |
|---|---|---|
| Web resources | Files that are part of the application | A logo, a stylesheet |
| Record files | A file belonging to one record | A contact's photo, a signed contract |
| Object storage | Files belonging to no record | A monthly export, an integration drop |
A record's file is attached to a record and travels with it. An export belongs to nobody's record — there is no row to hang it off — and that is what object storage is for.
Before you start
You need a bucket name. You do not need to create it: the first write creates it.
Bucket names are lowercase letters, digits and hyphens, 3–63 characters. They are shared across all
tenants, the way S3's are, so a plain name like exports may already be taken — prefix it with
something of yours, as par-exports does below.
What a script can do
putFile(bucket, key, text) | Writes a file. Answers true, or false if it could not |
putFileAs(bucket, key, text, contentType) | The same, saying what kind of file it is |
getFile(bucket, key) | Reads it back as text — the empty string if there is none |
fileExists(bucket, key) | true or false |
listFiles(bucket, prefix) | The files under a prefix, each with key, size, contentType and uploadedAt |
deleteFile(bucket, key) | Removes one |
The key is the file's path inside the bucket — claims/2026-09.csv. Slashes in it are just part
of the name, but they read as folders in the storage screen, so grouping by one is worth doing.
Steps
-
Open your solution and choose Custom APIs, then New.
-
Give it a route —
claims/export/{period}— and choose Post. -
In the script, build the file as text and write it:
var claims = runQuery({ entity: 'par_claim', select: ['par_name', 'par_amount'] });var csv: string = 'reference,amount\n';for (var i: int = 0; i < claims.length(); i++) {csv = csv + `${claims[i].par_name},${claims[i].par_amount}` + '\n';}putFileAs('par-exports', 'claims/' + getInput('period') + '.csv', csv, 'text/csv'); -
Save, and call it.
Three things that catch people out
length()needs its parentheses.claims.lengthis not an error — it is nothing, so a loop guarded by it never runs and your export comes out as a header row and no rows. This is the one mistake here that does not announce itself.- You cannot add a number to a string.
'Total: ' + amountis refused. Put the number in a backtick template instead —`Total: ${amount}`— which is the only way to turn one into text. - Say what kind of file it is.
putFilestores it as plain text, and a browser shows plain text in the tab instead of saving it. UseputFileAswithtext/csv,application/jsonor whatever it really is.
Confirm it worked
Go to Admin → Storage, open the bucket, and the file is there with its size and type. Download it and check the contents.
What this is not for
- Big binary files. A script's file is text it built. Images, PDFs and archives are handled by the platform itself, which streams them.
- A file that belongs to a record. Put a File or Image column on the table instead — it is served under the record's own permissions, and deleting the record takes the file with it.
- Giving somebody a link. These functions hand your script the text; they do not mint a URL. A link that works without signing in is a key anybody can pass on, so it is a deliberate step elsewhere rather than something a script does in passing.
Troubleshooting
"Bucket was not found." Reading or listing a bucket that does not exist yet — only writing creates one. Write first, or check the spelling.
Nothing in the file but the header. length without the parentheses. See above.
"Only numbers or strings are allowed in this expression." You added a number to a string. Use a backtick template.
"This run has no caller." The script is an asynchronous plugin step. Those run after the request that queued them has finished, so there is nobody to act as. Use a synchronous step, or a Custom API.
See also
- Publish a Custom API
- The
custom-api-object-storage-exportsample solution, which is this page as something you can import and run.