Skip to content
Docs · Data

Storage

Private object storage with no egress fees. Upload and download with signed URLs, so file bytes go straight between the client and storage.

View as Markdown
All topics
On this page

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.

presignsigned URLbytes go direct — no egress feesyour app12Harakumo APIobject storagepresign once — uploads never touch your servers

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

  1. The browser tells your server the file's size (file.size). Your server signs a PUT for a key, naming the Content-Type and that size.
  2. The browser PUTs the file to that URL within 15 minutes, with the same Content-Type header. Buckets accept browser uploads from any origin.
Server
// 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 browser
Browser
await fetch(url, { method: 'PUT', headers: { 'content-type': 'image/png' }, body: file });
  • An upload link lasts at most 15 minutes. A longer expiresIn is shortened to 900 rather than refused (any value up to 604800, the most any link may ask for): the answer's expiresIn is what the link got, requestedExpiresIn what you asked for, and note says 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. size is 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. Send size before a link goes into an app. The dashboard's Upload button and the SDK's hk.storage.upload send it for you.
  • A size over 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 413 file_too_large and no link. So is one over maxBytes, the most you will accept; max_bytes in the answer is the limit that refused it.
  • maxBytes without size is refused with 400 size_required, since a link cannot be held to "at most": send both.
  • contentType is 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.

  1. 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 unless partSize (5 MiB up to 5 GiB) says otherwise; every part but the last is that long.
  2. 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 each url within 15 minutes; a link takes no other length.
  3. Carry on after a dropped connection: GET /api/storage/:id/uploads/:uploadId lists the parts storage holds (uploaded) and the ones still missing; GET /api/storage/:id/uploads finds an open upload again by its key.
  4. Finish: { action: "complete" } joins the parts into the file (409 parts_missing, with missing, until every part is there). DELETE on 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.

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
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/:id lists objects: prefix narrows to a path, delimiter=/ groups keys into folders, and limit (up to 1,000, default 100) with cursor pages through; nextCursor is set while more remain.
  • POST { key, method: "delete" } removes one object. DELETE /api/storage/:id empties 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 size is 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 (402 usage_paused) only while a charge cannot be paid. Deleting files frees room once the bucket is measured again: daily, or right away with GET /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

SDK
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');