Files live in buckets; each bucket belongs to a project and is its own bucket in object storage. A file's key is its path (avatars/42.png); folders are just the part of the key before a slash.
Files are private. To upload or download, your server asks Harakumo for a signed URL for one key, and the client sends or fetches the bytes directly. There are no public bucket URLs and no S3-style access keys yet.
From a phone app, the app's backend function signs the link and the app sends the bytes: see Use Harakumo from a phone app.
In plain words
Private folders for your app's files, such as photos, documents and videos. People upload and download straight from their browser with links that stop working after a while, so nothing is public by accident.
For example: A customer attaches a photo of a damaged parcel to a support request.
Upload from the browser
- The browser tells your server the file's size (
file.size). Your server signs a PUT for a key, naming the Content-Type and thatsize. - The browser PUTs the file to that URL within 15 minutes, with the same Content-Type header. Buckets accept browser uploads from any origin.
// size: the file's length in bytes, sent by the browser
const { url, expiresAt } = await hk.storage.presign(bucketId, {
key: 'avatars/' + userId + '.png', method: 'put', contentType: 'image/png',
size, maxBytes: 5 * 1024 * 1024, // refuse anything over 5 MB before a link exists
});
// hand url to the browserawait fetch(url, { method: 'PUT', headers: { 'content-type': 'image/png' }, body: file });Upload links
- An upload link lasts at most 15 minutes. A longer
expiresInis shortened to 900 rather than refused (any value up to 604800, the most any link may ask for): the answer'sexpiresInis what the link got,requestedExpiresInwhat you asked for, andnotesays so. - With
size, the file's exact length in bytes, the link accepts exactly that many bytes and no other length, so nobody who gets hold of it can upload more.sizeis optional on every plan, Hobby included, but a link without it is not locked to any length: whoever holds it can send one file of up to 5 GB. Sendsizebefore a link goes into an app. The dashboard's Upload button and the SDK'shk.storage.uploadsend it for you. - A
sizeover the plan's per-file cap (1 GB on Hobby, all the file storage it includes; 5 GB on the other plans) is refused with 413file_too_largeand no link. So is one overmaxBytes, the most you will accept;max_bytesin the answer is the limit that refused it. maxByteswithoutsizeis refused with 400size_required, since a link cannot be held to "at most": send both.contentTypeis signed too: the PUT must send exactly that Content-Type.
Resumable uploads
For a big file, or a phone on a bad network: the file goes up in parts, each straight to storage on its own link, so a part that fails is sent again on its own and an upload left half done carries on later from the parts already there.
- Start it:
POST /api/storage/:id/uploads { key, size, contentType?, partSize?, maxBytes? }→{ uploadId, partSize, partCount, expiresAt }.size, the file's exact length, is required here. Parts are 8 MiB unlesspartSize(5 MiB up to 5 GiB) says otherwise; every part but the last is that long. - Get links:
POST /api/storage/:id/uploads/:uploadId { action: "sign", parts: [1, 2, …] }, up to 100 parts a call. PUT exactly that part's bytes to eachurlwithin 15 minutes; a link takes no other length. - Carry on after a dropped connection:
GET /api/storage/:id/uploads/:uploadIdlists the parts storage holds (uploaded) and the ones stillmissing;GET /api/storage/:id/uploadsfinds an open upload again by its key. - Finish:
{ action: "complete" }joins the parts into the file (409parts_missing, withmissing, until every part is there).DELETEon the upload stops it and drops its parts.
One file is held to the same per-file cap as a single upload (413 file_too_large). An upload must be finished within six days of its start (410 upload_expired after). In Node, SDK 0.3.0 and later does all of it with hk.storage.uploadResumable(bucketId, key, data), and hk.storage.uploads has each call for a server that hands a phone the part links. Starting, signing and finishing need the developer role (a full key); any member can read an upload.
Download links
Sign a GET for a key and send the person (or an img tag) to the URL. Links last 15 minutes by default; pass expiresIn from 60 seconds to 7 days (604800). Viewers and read-only keys get download links of at most 15 minutes. For files shown to many people, sign a longer link once and reuse it instead of signing on every view.
curl -X POST https://harakumo.com/api/storage/7 \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{ "key": "reports/q3.pdf", "method": "get", "expiresIn": 86400 }'
# → { "url": "…", "expiresIn": 86400, "expiresAt": "…" }Listing and deleting
GET /api/storage/:idlists objects:prefixnarrows to a path,delimiter=/groups keys intofolders, andlimit(up to 1,000, default 100) withcursorpages through;nextCursoris set while more remain.POST { key, method: "delete" }removes one object.DELETE /api/storage/:idempties and deletes the bucket.- Sizes and file counts are measured daily (or on
?measure=1) and shown as a dash until a full count exists.
Who can do what
- Viewers and read-only keys can list and get download links.
- Uploading and deleting need the developer role (a full key).
- A key limited to the project works for all of it.
Limits and scaling
- One file is at most 5 GB, in one signed PUT or as a resumable upload in parts. On Hobby a link with
sizeis at most 1 GB, all the file storage the plan includes. - Keys are up to 1,024 characters.
- Buckets per plan: 2 hobby, 20 pro, 100 team, 500 scale.
- File storage per plan, all buckets together: 1 GB hobby, 50 GB pro, 250 GB team, 1,000 GB scale, measured daily (on Hobby, across all your free workspaces; on a paid plan, across the workspaces it covers; a Pro subscription from before these plans keeps 100 GB). On Hobby, new uploads are refused once the plan's file storage allowance is used up (402
usage_limit). On Pro, Team and Scale, use past it is charged daily from the paying workspace's Balance at $0.05 per GB a month (Team's $20 monthly usage credit pays first), and new uploads pause (402usage_paused) only while a charge cannot be paid. Deleting files frees room once the bucket is measured again: daily, or right away withGET /api/storage/:id?measure=1(for a bucket of up to 10,000 files). - The bytes never pass through Harakumo; each signed URL is one API call.
Reference
const { bucket } = await hk.storage.create(project.id, { name: 'uploads' });
const { url } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'put', contentType: 'image/png', size: 48213 });
const { url: download } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'get', expiresIn: 3600 });
const { objects, folders, nextCursor } = await hk.storage.objects(bucket.id); // first page
await hk.storage.deleteObject(bucket.id, 'file.png');harakumo storage 12 # list buckets
harakumo storage create 12 uploadsPOST /api/projects/:id/storage { "name": "uploads" }
POST /api/storage/:id { "key": "file.png", "method": "put"|"get"|"delete", "contentType"?, "expiresIn"?, "size"?, "maxBytes"? }
GET /api/storage/:id?prefix=&delimiter=/&limit=&cursor= → { objects, folders, nextCursor, truncated }
DELETE /api/storage/:id # empties and deletes the bucket
POST /api/storage/:id/uploads { "key", "size", "contentType"?, "partSize"?, "maxBytes"? } # a resumable upload
GET /api/storage/:id/uploads # open uploads, newest first
GET /api/storage/:id/uploads/:uploadId → { uploaded, missing } while open
POST /api/storage/:id/uploads/:uploadId { "action": "sign", "parts": [1, 2] } | { "action": "complete" }
DELETE /api/storage/:id/uploads/:uploadId # stop it and drop its parts