On 30 July 2026 a developer asked on the Atlassian developer community how to set a file name for an object inside Forge Object Store. My colleague Mihai (Mihai_leanzero on the forum) answered with a workaround: keep the name yourself and save the downloaded file from JavaScript. An Atlassian staff member who had hit the same wall opened FRGE-2229 for it. On 9 November 2026 that ticket was still in Reviewing with 3 votes, and the thread had 119 views and no accepted answer. You can keep the name today, though: we saved the downloaded file under its original name from both UI Kit and Custom UI.
So we built the whole thing on our Jira test site, to see what you get today. The Forge Object Store is Atlassian's file storage for Forge apps (Forge is Atlassian's platform for apps that run on Atlassian's own cloud). It holds objects of up to 1 GB, and your code never touches the bytes. A resolver (your app's backend function) asks for a pre-signed URL, a link that carries its own permission to upload or download one object, and the browser talks to the store directly. Our 2,798-byte test PNG went up and came back with the same SHA-256 checksum from UI Kit, from Custom UI and from a server-side fetch.
Along the way we found two things the docs don't tell you, and one documented feature we couldn't get to work. One of the two (overwrite: false replacing an object anyway) is worth knowing before you build on it.
Below are the steps and the files you need, for both of Forge's front ends: UI Kit (Atlassian's own React components, rendered by Jira) and Custom UI (your own HTML and JavaScript in an iframe). If you've been stuck on Forge's payload limit for front-end calls, this is the way around it.
Note
Prerequisites — A Jira Cloud site where you can install apps (we used wolfaenpak.atlassian.net, our test site), the Forge CLI logged in (we ran 14.1.0 on Node.js 24.15.0; the app runs on the nodejs22.x runtime), and a Forge developer space (the space your apps are registered to;
forge developer-spaces listshows yours). Package versions we tested: @forge/object-store 3.1.0, @forge/react 12.5.0, @forge/bridge 7.3.0, @forge/resolver 2.0.2 and React 18.3.1. The UI Kit hook needs Forge CLI 12.8.0 or newer and UI Kit 11.7.0 or newer. The Object Store doesn't work in Bitbucket apps or in Atlassian Government Cloud. When you're done you'll have a Jira app that uploads a file, reads its metadata, downloads it under its original name and deletes it.
[[steps]]
- Add the objectStore module — one module, one resolver function, and a major version you should expect (Step 1).
- Sign upload and download URLs in a resolver — @forge/object-store mints the URLs and the browser moves the bytes (Step 2).
- Upload and download from UI Kit — the hook and two UI Kit components, then save under the original name (Step 3).
- Upload from Custom UI — the objectStore bridge API with a plain file input (Step 4).
- Check what you stored — read the metadata back, then delete the object (Step 5).
How the Forge Object Store moves a file
You never pass file bytes through a resolver. Forge caps a front-end call to a resolver at 500 KB (invocation limits). And the JSON body your resolver sends to the store, which is not the file, is capped at 1 kB: enough for a key and a checksum. So an upload goes in three hops:
- The front end hashes the file with SHA-256 in the browser and sends the resolver the file's length and checksum.
- The resolver picks a key, calls
createUploadUrlwith it, and returns the pre-signed URL. - The browser sends the file with a PUT request to that URL. The store checks the bytes against the checksum it signed.
A download is the same idea in reverse: the resolver calls createDownloadUrl(key) and the browser does a GET request on the URL it gets back. In our run every URL pointed at tdp-os.services.atlassian.com, and neither UI needed a permissions.external.fetch entry in the manifest for that host.
Atlassian calls the Object Store Preview, and its release phases page says a Preview feature is "suitable and operationally supported for production apps", with "the same level of support as GA features". What you give up is notice. The overview page says the store is still under active development, and Atlassian promises at least 1 month before a breaking change in Preview, against 6 months once a feature is GA (generally available).
Step 1. Add the objectStore module (it forces a major version upgrade)
You need one objectStore module, a resolver function, and a page for each UI. We put the UI Kit page on jira:globalPage and the Custom UI page on jira:projectPage, because a manifest may only define one jira:globalPage.
- In a terminal, create the project folder:
mkdir lz-object-store-demo && cd lz-object-store-demo && npm init -y. npm writes apackage.json. - Run
npm pkg delete typeto remove the"type": "commonjs"line npm may have added topackage.json. With it, the bundler stops atimport Resolver from '@forge/resolver'(the error is in "If it didn't work" below). - Create
manifest.ymlwith the modules below, without theid:line underapp. - Create the folder
static/customui/buildwith anindex.htmlin it (an empty file is enough until Step 4).forge lintrefuses the manifest while that folder is missing. - Register the app:
forge register lz-object-store-demo. In a normal terminal it asks you to pick a developer space and to accept the developer terms. It prints "Registered lz-object-store-demo" and adds the app id tomanifest.yml. - Install the packages:
npm install @forge/object-store@latest @forge/react@latest @forge/bridge@latest @forge/resolver@latest react@18. Thennpm ls --depth=0should list @forge/bridge@7.3.0, @forge/object-store@3.1.0, @forge/react@12.5.0, @forge/resolver@2.0.2 and react@18.3.1.
We ran register from a script, where the CLI can't show its prompts, so we passed the developer space id (from forge developer-spaces list) and the terms answer on the command line:
forge register lz-object-store-demo -y -s <your developer space id>
modules:
objectStore:
- key: fos
storageClass: standard
jira:globalPage:
- key: fos-demo-page
title: Object Store demo
resource: main
resolver:
function: resolver
render: native
jira:projectPage:
- key: fos-demo-customui
title: Object Store demo (Custom UI)
resource: customui
resolver:
function: resolver
function:
- key: resolver
handler: backend/index.handler
resources:
- key: main
path: src/frontend/index.jsx
- key: customui
path: static/customui/build
app:
runtime:
name: nodejs22.x
id: ari:cloud:ecosystem::app/<your app id>
The id line in the listing is what forge register adds. The objectStore module page says you can define the module once per app, standard is the only storage class, and adding it "will trigger a major version upgrade". That matters for an app people already use: majors need admin consent, so existing installations won't pick up the store until an admin approves the new version or you roll it out with bulk upgrades. Mihai keeps a list of the Forge changes that force a major version from building Sentinel Vault.
Our first successful deploy, on a brand-new app, was numbered 2.0.0 rather than 1.0.0. We think the objectStore module's major bump is why, but we didn't prove it.
Step 2. Sign upload and download URLs in a resolver with @forge/object-store
The resolver is where you decide the key, the TTL (time to live: how long the store keeps the object) and who may upload. Your front end only ever sees URLs.
- Create
src/backend/index.jswith the code below. - Keep
@forge/bridgeout of this file. It's a browser package, and importing it in a resolver breaks the function (what happens when you do). - After the Step 3 deploy, run
forge logs -e developmentwhile you upload a file. You should see acreateUploadUrlline whosereturnedKeysis["url"], like ours (the resolver cuts the signed part of the URL before logging):
INFO 2026-11-09T09:35:27.495Z createUploadUrl {"body":{"key":"demo-1794216927116-0","length":2798,"checksum":"1UWPYsKL5ukcDUtN8YziiRX5ZSuRb6puNByhdhdZJSQ=","checksumType":"SHA256"},"returnedKeys":["url"],"url":"https://tdp-os.services.atlassian.com/fos/app/upload/4b4dd309-936d-45ed-9002-f77259ca78f6/c9158d4f805385dfb24eb8642bb78538ed1a319ee97c07fcaa19727d6635f8d4?X-Amz-..."}
import Resolver from '@forge/resolver';
import fos from '@forge/object-store';
const resolver = new Resolver();
// Redact the signed query string so logs and the UI never carry a usable URL.
const redact = (url) => (typeof url === 'string' ? url.split('?')[0] + '?X-Amz-...' : url);
const newKey = (i) => `demo-${Date.now()}-${i}`;
// Shared by the two upload resolvers. useObjectStore / bridge send
// { allObjectMetadata: [{ length, checksum, checksumType }] } and expect
// back { [presignedUrl]: { key, checksum, ... } }.
async function buildUploadUrls(allObjectMetadata, ttlSeconds) {
const out = {};
for (const [i, meta] of allObjectMetadata.entries()) {
const key = newKey(i);
const body = { key, length: meta.length, checksum: meta.checksum, checksumType: meta.checksumType };
if (ttlSeconds) body.ttlSeconds = ttlSeconds;
const res = await fos.createUploadUrl(body);
console.log('createUploadUrl', JSON.stringify({ body, returnedKeys: Object.keys(res || {}), url: redact(res && res.url) }));
out[res.url] = { key, checksum: meta.checksum, length: meta.length, checksumType: meta.checksumType, ttlSeconds };
}
return out;
}
resolver.define('generateUploadUrls', async ({ payload }) => buildUploadUrls(payload.allObjectMetadata, undefined));
resolver.define('generateUploadUrlsTtl', async ({ payload }) => buildUploadUrls(payload.allObjectMetadata, 3600));
// bridge download sends { keys } and expects { [presignedUrl]: key }.
resolver.define('generateDownloadUrls', async ({ payload }) => {
const out = {};
for (const key of payload.keys) {
const res = await fos.createDownloadUrl(key);
console.log('createDownloadUrl', JSON.stringify({ key, returnedKeys: Object.keys(res || {}), url: redact(res && res.url) }));
if (res && res.url) out[res.url] = key;
}
return out;
});
// bridge delete sends { key } once per key.
resolver.define('deleteObject', async ({ payload }) => {
const res = await fos.delete(payload.key);
console.log('delete', JSON.stringify({ key: payload.key, returned: res === undefined ? 'undefined' : res }));
return { key: payload.key, deleted: true };
});
// bridge getMetadata sends { key } once per key.
resolver.define('getObjectMetadata', async ({ payload }) => {
const ref = await fos.get(payload.key);
console.log('get', JSON.stringify({ key: payload.key, ref: ref === undefined ? 'undefined' : ref }));
return ref ? ref : { key: payload.key, error: 'not found (fos.get returned undefined)' };
});
export const handler = resolver.getDefinitions();
These are the five resolvers of the app we deployed. Our file also had test resolvers for the checks further down, which you don't need.
The docs don't show your resolver's contract in one place, so this step puts it together from the shipped code. The useObjectStore page names the function keys but doesn't show what your resolver receives or must return. The sample on the Custom UI objectStore page reads objectMetadata from the payload and calls createUploadUrl with two arguments. The shipped code does something else. In @forge/bridge 7.3.0 the upload sends { allObjectMetadata }, and in @forge/object-store 3.1.0 createUploadUrl takes one body and returns an object whose only field is url. Each value needs the key you chose and the file's checksum, because the bridge matches each URL back to its file by checksum. The bridge reads only those two fields and ignores the rest of our map. Atlassian's own sample app does it the way our code does, so copy from the sample app.
The other calls are simpler. Download gets { keys } and returns { [url]: key }. Delete and metadata are called once per key with { key }.
Step 3. Upload and download files from UI Kit with useObjectStore
UI Kit gives you a FilePicker component and the useObjectStore hook. The hook hashes the file, calls your resolver, does the PUT and keeps a list of objects in objectStates.
- Create
src/frontend/index.jsxwith the code below. - In the terminal, run
forge deploy -e development. The CLI runsforge lint, then prints "Deployed lz-object-store-demo to the development environment." - Run
forge install --site <your-site>.atlassian.net --product jira -e development. On a paid Jira plan it first warns that you're installing a development app on a production site and asks "Are you sure you want to proceed?"; answer yes. (On a Free, developer or sandbox site it doesn't ask.) It prints "Install in Jira complete!" and a "Check it out here" link to the page. - In Jira, open Apps and select Object Store demo. If it isn't listed, open the "Check it out here" link that
forge installprinted (we opened the page that way). The page shows the TTL toggle and an Upload a file drop zone. - Select browse and pick a small file. You should see a FileCard appear with the file's name and size. Below it,
objectStatesshows the key your resolver made, with"status": 201. - Select Save with a file name. The browser saves the file under its original name (
lz-object-store-test.pngin our run).
import React, { useEffect, useRef, useState } from 'react';
import ForgeReconciler, {
Box, Button, ButtonGroup, CodeBlock, FileCard, FilePicker, Heading, Inline, Stack, Text, Textfield, Toggle, Label, useObjectStore,
} from '@forge/react';
import { invoke } from '@forge/bridge';
// FilePicker hands over base64; strip a data: prefix if one is present.
const toBase64 = (data) => (data.includes(',') && data.startsWith('data:') ? data.split(',')[1] : data);
const App = () => {
const { objectStates, uploadObjects, downloadObjects, deleteObjects } = useObjectStore();
const [names, setNames] = useState({});
const [useTtl, setUseTtl] = useState(false);
const [result, setResult] = useState(null);
const pending = useRef([]);
const [checkKey, setCheckKey] = useState('');
// Give each new real key the file name the user picked (the store keeps no file name).
useEffect(() => {
const fresh = objectStates.filter((o) => !o.key.startsWith('temp-upload-') && !names[o.key]);
if (fresh.length && pending.current.length) {
const next = { ...names };
fresh.forEach((o) => { if (pending.current.length) next[o.key] = pending.current.shift(); });
setNames(next);
}
}, [objectStates]);
const onPick = async (files) => {
if (!files || !files.length) return;
pending.current.push(...files.map((f) => f.name));
await uploadObjects({
functionKey: useTtl ? 'generateUploadUrlsTtl' : 'generateUploadUrls',
objects: files.map((f) => ({ data: toBase64(f.data), mimeType: f.type, fileSize: f.size })),
});
};
// Download the Blob, then save it under the name we kept.
const saveAs = async (key) => {
const [r] = await downloadObjects({ functionKey: 'generateDownloadUrls', keys: [key] });
const url = URL.createObjectURL(r.blob);
const a = document.createElement('a');
a.href = url; a.download = names[key] || key;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
};
const call = async (fn, payload) => setResult({ action: fn, result: await invoke(fn, payload) });
const ready = objectStates.filter((o) => o.success && !o.isUploading);
return (
<Stack space="space.200">
<Heading as="h2">Forge Object Store: upload and download</Heading>
<Inline space="space.100" alignBlock="center">
<Toggle id="ttl" isChecked={useTtl} onChange={() => setUseTtl(!useTtl)} />
<Label labelFor="ttl">Expire after one hour (ttlSeconds: 3600)</Label>
</Inline>
<FilePicker label="Upload a file" description="Stored in the app's Object Store" onChange={onPick} />
{objectStates.map((o) => (
<Stack key={o.key} space="space.050">
<FileCard
fileName={names[o.key] || o.key}
fileSize={o.objectSize}
fileType={o.objectType}
isUploading={o.isUploading}
error={o.error}
onDelete={o.success && !o.isUploading ? () => deleteObjects({ functionKey: 'deleteObject', keys: [o.key] }) : undefined}
/>
<Text>Key: {o.key}</Text>
</Stack>
))}
{ready.length > 0 && (
<ButtonGroup>
<Button onClick={() => call('getObjectMetadata', { key: ready[ready.length - 1].key })}>Read metadata (get)</Button>
<Button onClick={() => saveAs(ready[ready.length - 1].key)}>Save with a file name</Button>
</ButtonGroup>
)}
<Inline space="space.100" alignBlock="end">
<Textfield name="checkKey" placeholder="Any object key" value={checkKey} onChange={(e) => setCheckKey(e.target.value)} />
<Button onClick={() => call('getObjectMetadata', { key: checkKey })}>Get this key</Button>
</Inline>
{result && <Box><CodeBlock language="json" text={JSON.stringify(result, null, 2)} /></Box>}
<CodeBlock language="json" text={JSON.stringify({ objectStates }, null, 2)} />
</Stack>
);
};
ForgeReconciler.render(<React.StrictMode><App /></React.StrictMode>);
This is a shortened copy of the page we deployed. Ours also had the FileCard's own download button, a server-side hash button and a few test buttons (you'll see some in the screenshots), and it showed the upload result on screen. While a file uploads, the hook gives it a placeholder key starting with temp-upload- and swaps in the real key when the upload finishes; the pending queue just pairs names with keys in the order you picked the files, which is fine for a demo. The saveAs function uses document and URL.createObjectURL. That worked in our run, because the UI Kit front end runs in the app's own iframe.
We ran the deploy and the install from a script, so we added --non-interactive, which also answers the install's question for you. They printed this:
forge deploy -e development --non-interactive
Running forge lint...
No issues found.
Deploying lz-object-store-demo to development...
ℹ Packaging app files
ℹ Uploading app
ℹ Validating manifest
ℹ Deploying to environment
✔ Deployed
Deployed lz-object-store-demo to the development environment.
ℹ The version of your app [2.0.0] that was just deployed to [development] is eligible for the Runs on Atlassian program.
forge install --site wolfaenpak.atlassian.net --product jira -e development --non-interactive
Installing your app onto an Atlassian site.
Press Ctrl+C to cancel.
WARNING: You are about to install a development app to a production site. If you do not intend to test your app, we strongly recommend that you deploy your app to production and install the production version instead.
Installing your app in Jira on to development...
✔ Install in Jira complete!
Your app in the development environment is now installed in Jira on wolfaenpak.atlassian.net. You can install the app into other Atlassian apps by running the forge install command again.
Check it out here: https://wolfaenpak.atlassian.net/jira/apps/65c4a2ff-dca0-49b8-8a64-a85001e01544/d571cbd9-a85a-44a0-8cd3-2ee3c7d7866a
Watch objectStates. The new key shows up there and nowhere else, because uploadObjects returns nothing. And it lives in React state, so it's empty again after a page reload. I was surprised the store has no list call. Its API has seven methods: get, delete, createUploadUrl, createDownloadUrl, createPublicUploadUrl, createPublicDownloadUrl and createCDNUrl. So your app has to remember its own keys, in the Key-Value Store or Forge SQL.
Step 4. Upload from Custom UI with the objectStore bridge
Custom UI apps (your own HTML and JavaScript in an iframe) use objectStore from @forge/bridge. It calls the same resolvers. Nothing changes on the backend.
- Create
static/customui/src/main.jswith the code below, andstatic/customui/src/index.htmlwith the HTML after it. - In the terminal, bundle it:
npx -y esbuild@0.25 static/customui/src/main.js --bundle --format=iife --minify --outfile=static/customui/build/main.js, then copyindex.htmlintostatic/customui/build/. - Run
forge deploy -e developmentagain. You don't need to install again: this deploy is a minor version, and our installation moved to it by itself (forge install listshowed Up-to-date). - In Jira, open a project and select the Object Store demo (Custom UI) tab. The page shows a file input and four buttons.
- Pick a file and select Upload. You should see
"success": trueand"status": 201in the output. - Select Download + save. The browser saves the file under its original name, and the output shows the same
sha256as the upload.
import { objectStore } from '@forge/bridge';
const out = document.getElementById('out');
const show = (o) => { out.textContent = JSON.stringify(o, null, 2); };
let lastKey = null;
let lastName = null;
async function sha256b64(blob) {
const buf = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
document.getElementById('up').onclick = async () => {
const f = document.getElementById('file').files[0];
if (!f) return show({ error: 'no file picked' });
const res = await objectStore.upload({ functionKey: 'generateUploadUrls', objects: [f] });
lastKey = res[0] && res[0].key; lastName = f.name;
show({ action: 'objectStore.upload', localSha256: await sha256b64(f), result: res });
};
document.getElementById('meta').onclick = async () => {
show({ action: 'objectStore.getMetadata', result: await objectStore.getMetadata({ functionKey: 'getObjectMetadata', keys: [lastKey] }) });
};
document.getElementById('dl').onclick = async () => {
const [r] = await objectStore.download({ functionKey: 'generateDownloadUrls', keys: [lastKey] });
const o = { action: 'objectStore.download', key: r.key, success: r.success, status: r.status };
if (r.blob) {
o.bytes = r.blob.size; o.sha256 = await sha256b64(r.blob);
const url = URL.createObjectURL(r.blob);
const a = document.createElement('a');
a.href = url; a.download = lastName || lastKey;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
o.savedAs = a.download;
}
show(o);
};
document.getElementById('del').onclick = async () => {
await objectStore.delete({ functionKey: 'deleteObject', keys: [lastKey] });
show({ action: 'objectStore.delete', key: lastKey, result: 'resolved' });
};
<!doctype html>
<html><head><meta charset="utf-8"><title>Object Store Custom UI</title></head>
<body>
<h2>Object Store via @forge/bridge (Custom UI)</h2>
<div class="row"><input type="file" id="file" /></div>
<div class="row">
<button id="up">Upload</button><button id="meta">getMetadata</button><button id="dl">Download + save</button><button id="del">Delete</button>
</div>
<pre id="out">Pick a file, then Upload.</pre>
<script src="./main.js"></script>
</body></html>
That's our index.html without its <style> block, which the iframe didn't apply anyway (we think Custom UI's content security policy blocked it, but didn't test that).
The Custom UI file above is a shortened copy of the deployed one, without its error handling and without the file, blobType and error fields in its output.
Step 5. Check what you stored: metadata, TTL and delete
Now read it back. You want proof the store holds what you think it holds.
- On the UI Kit page, select Read metadata (get). You should see five fields:
key,checksum,size,createdAtandcontentType. - Compare the checksum with your file's: run
openssl dgst -sha256 -binary <file> | base64in a terminal. The two strings match. - Optional: turn on the Expire after one hour toggle and upload another file. Its metadata looks the same, because the store doesn't report a TTL.
- Copy the key, then select the X on the FileCard to delete the object. Paste the key into the Any object key field and select Get this key. The resolver now returns "not found (fos.get returned undefined)".
openssl dgst -sha256 -binary lz-object-store-test.png | base64
1UWPYsKL5ukcDUtN8YziiRX5ZSuRb6puNByhdhdZJSQ=
What get returned for the same object, from our forge logs -e development:
{"key":"demo-1794216927116-0","checksum":"1UWPYsKL5ukcDUtN8YziiRX5ZSuRb6puNByhdhdZJSQ=","size":2798,"createdAt":"2026-11-09T09:35:27.890Z","contentType":"image/png"}
It's the same checksum. And that contentType came from the PUT's Content-Type header, which the bridge sets from the file's type. The API page says the store keeps it and returns it on download, and without it you get application/octet-stream. A missing key doesn't throw. For a key that never existed, get, createDownloadUrl and delete all came back undefined, so check for that before you use the result.
Forge Object Store default TTL: no expiry unless you set ttlSeconds
If you leave ttlSeconds out of createUploadUrl, the object never expires. It stays until your app deletes it or a customer uninstalls the app. The API page says "If omitted, the object won't expire", and an Atlassian staff member said the same in the default TTL thread in July 2026. You'll still find answers saying 90 days. Those come from the Early Access Program (EAP), when the old os.put method defaulted to 90 days; that method is now on the deprecated methods page.
We tested a 60-second TTL. It worked. An object uploaded from the server with ttlSeconds: 60 at 09:41:12 UTC was there right after the upload and gone when we asked again at 09:43:42 and at 09:45:04. ttlSeconds: 0 was refused with ForgeObjectStoreError, code BAD_REQUEST, "Field validation failed".
Three lifetimes are in play. They're easy to mix up:
| Lifetime | Default | Limit |
|---|---|---|
The object (ttlSeconds on upload) |
never expires | no maximum documented |
| A pre-signed upload or download URL | 1 hour | fixed at 1 hour |
A CDN (content delivery network) URL, from createCDNUrl with ttlSeconds
|
29 days | more than 0 and at most 2,505,600 seconds (29 days) |
You can't change an object's TTL after upload in the stable package. The 3.1.0 CHANGELOG and README announce copy and touch (touch renews a TTL), but the shipped 3.1.0 code has neither: at runtime typeof fos.copy and typeof fos.touch were both "undefined" for us. They do ship in the pre-release 3.1.0-next.2, and I'd wait for a stable release before building on them. Until then a new TTL means uploading the bytes again. So I'd set ttlSeconds on anything temporary, like an export, when you upload it, because nothing else will clean it up. If you also use the Key-Value Store, its TTL works differently again; Mihai covers it in KVS locking with FAIL_IF_EXISTS vs TTL leases.
How to set a file name for a Forge Object Store download
The store can't keep a file name. The upload body in 3.1.0 is { key, length, checksum, checksumType, ttlSeconds?, overwrite?, cdn? }, and nothing in the package takes a file name. When our resolver fetched a download URL itself, the response had content-type: image/png and no content-disposition header.
So keep the name yourself; the code for it is short. In this demo the name sits in front-end state, which is fine for a demo and wrong for a real app: store a key-to-name map in the Key-Value Store or Forge SQL. Then download the Blob and save it through an <a download="..."> link, like saveAs in Step 3. We tested it in UI Kit and in Custom UI, and in both, headless Chromium saved lz-object-store-test.png with bytes identical to the original. We checked with cmp. The developer who asked on the community thread worried that some browsers block a JavaScript download from an iframe. We only ran Chromium, so try Firefox and Safari before you rely on it.
UI Kit has a documented route for this too. The FileCard docs say that if onDownload returns a Promise of a Blob, the card "will automatically trigger a browser download using the fileName". Our handler returned the Blob with the right bytes, but in headless Chromium no download started within 30 seconds. We didn't try a normal browser window, so maybe it's only a headless quirk. The <a download> route worked in both UI Kit and Custom UI, so that's the one I'd use.
Forge Object Store limits: 1 GB objects, 1-hour URLs, 5,000 requests a minute
From the limits page (the two request rates are per installation):
| Limit | Value |
|---|---|
| Maximum object size (also through useObjectStore) | 1 GB |
| Object Store requests per minute | 5,000 |
| Pre-signed URL requests per second | 1,000 |
| Maximum request payload size | 1 kB |
| Pre-signed URL validity | 1 hour |
The page sets no limit on how long you keep an object. It lists no key-length or object-count limit either. A 1,100-character key made createUploadUrl throw BAD_REQUEST with "Invalid JSON body". We think that's the 1 kB request payload limit, though the message doesn't mention size. Over the rate limit, the shipped code throws RATE_LIMIT_EXCEEDED with "You have exceeded the rate limits for OS. Please wait and try again later." (the limits page calls it TOO_MANY_REQUESTS).
Two results from our 9 November 2026 runs aren't in the docs:
-
overwrite: falsedidn't protect an existing object. We uploaded to a key, asked for a second upload URL withoverwrite: false, and got one. The second PUT returned 201 andgetshowed the new checksum. We ran it twice, and it did the same both times. That one surprised me. The docs also disagree on the default (the API page saystrue, the bridge page saysfalse). I wouldn't rely on it. Callgetfirst, or make every key unique like ournewKey. - The signed length wasn't enforced. A URL signed for 29 bytes accepted a 34-byte PUT, and the object was stored at 34 bytes. So the length you sign doesn't cap what gets stored. The checksum is enforced, though. A PUT with bytes that didn't match it got a 400 and stored nothing.
Pricing is per request. The Forge platform pricing page gives 5,000 Object Store requests a month free per app, then $0.001353 per 1,000, and usage counts in every environment, development included. The page has no storage-volume line for the Object Store, and it doesn't define whether one "request" is the URL mint or the PUT.
Deploying to production: "Forge Object Store cannot be used in non development environment"
Our app only ever ran in the development environment, where this error doesn't come up. It was reported in July 2026 in Forge object store cannot yet be used in production: the app threw "Forge Object Store cannot be used in non development environment" outside development. The person who asked later explained that a developer on their team had enrolled the real app ids in the EAP, and the EAP flag had to be removed from the app before it worked in production. That fits how Atlassian describes EAP features, which "can only be enabled on development environments", while Preview features can be enabled in production. If you see this error, ask Atlassian support to check for a leftover EAP flag on your app.
Before you ship, two more facts:
- Uninstalling soft-deletes the app's objects. Reinstalling doesn't bring them back by itself. The overview page gives you 21 days to submit a recovery request. An Atlassian staff member confirmed that 21-day relink window in a September thread. For an object your app deletes, the same reply says the soft-deleted copy is kept for 30 days, while the overview says to raise a ticket within 21 days of the deletion, and the reply adds that the team will review the docs. Until a page settles it, I'd plan on 21.
- The Object Store is data-residency enabled, like the Key-Value Store, the Custom Entity Store and Forge SQL, and an app that keeps all its in-scope data there meets the storage criterion for the Runs on Atlassian badge. Our deploy said the same thing: "eligible for the Runs on Atlassian program".
If it didn't work
These are the errors we hit building the demo, in order.
forge register printed Error: unknown option '--non-interactive', because it has no such flag. Without it, it printed Error: Failed to fetch or select Developer Space: Prompts can not be meaningfully rendered in non-TTY environments. Passing -y -s <developer space id> fixed both.
forge lint refused the first manifest with Only a single entry of the jira:globalPage module can be defined in the manifest valid-module-required. Put your second UI on another module, like jira:projectPage.
The bundler then stopped with Bundling failed: Module parse failed: 'import' and 'export' may appear only with 'sourceType: module'. Run npm pkg delete type to take "type": "commonjs" out of package.json, and it goes through.
The last one was a TTL that didn't stick. In our first run the toggle click missed, so the "one hour" upload had no TTL. We caught it because the createUploadUrl line in forge logs had no ttlSeconds. So log the body you send.
What you built, and what production would add
You now have a Jira app that stores files in the Forge Object Store from two front ends.
[[takeaways]]
- A resolver signs upload and download URLs with @forge/object-store 3.1.0, and the browser moves the bytes. The 2,798-byte test file came back with the same SHA-256 every way we downloaded it.
- The UI Kit page and the Custom UI tab share one resolver file, and both use the payloads the shipped @forge/bridge 7.3.0 sends.
- Files download under their original name through a Blob and an a[download] link, because the store keeps no name.
- An omitted ttlSeconds keeps an object forever; ttlSeconds 60 removed one within 150 seconds.
- Not covered: files near the 1 GB limit (ours were 2.7 KB), CDN (content delivery network) URLs, the public URL methods, copy and touch from the pre-release package, and the FileCard download button in a normal browser window.
For production, store each key with its file name and owner in the Key-Value Store or Forge SQL, because the Object Store can't list keys. Check that the caller may read a key before you sign a download URL for it; the sample on Atlassian's bridge page scopes keys to the user's account id. Check get before an upload that must not replace anything. And plan the major version upgrade with your existing installations in mind. If you try the Object Store with bigger files, I'd love to hear what you hit. Did the FileCard button work for you in a normal browser? Tell me in the comments here or on the developer community thread.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.






Top comments (0)