Files
Upload the brief, attach it to the campaign, hand out a link that expires. The bytes never touch our API.
Sarah's spring campaign has a brief — a PDF from the agency. It belongs with the campaign record, it mustn't be reachable by guessing a URL, and it shouldn't crawl through our API server on its way to storage.
Files is the environment's file catalog: each file is a row in the same database as your custom data, and an object in storage under a prefix that is yours alone. Uploads go straight to storage through a short-lived URL; downloads come back through an expiring URL; every upload is virus-scanned before anyone can read it.
Server-side, in this release
Files are reached with a secret (sk_) key. Your backend uploads on a user's behalf
and hands them the download URL. End-user access rules are a later release.
Three steps, or one
Upload
The SDK does the dance for you: announce the file, PUT the bytes where the API
says, complete.
const brief = await hb.files.upload(pdfBytes, {
name: "spring-brief.pdf",
contentType: "application/pdf",
metadata: { kind: "brief" },
});
// brief.status === "ready", brief.scan_status === "pending"Doing it by hand — from a mobile app, say — is the same three calls:
POST /api/modules/files/uploads { "name", "content_type", "size", "metadata"? }
→ 201 { "data": { "id", ... }, "upload": { "url", "method": "PUT", "headers", "expires_at" } }
PUT <upload.url> the bytes, with the headers given (no API key)
POST /api/modules/files/{id}/complete → 200, verifies the object's size against what you declaredThe upload URL is bound to the declared size and content type and lives a few
minutes. The object key is ours (app/…/env/…/{id}), never your file name.
Wait for the scan
Every upload is scanned for malware off the request. Until the verdict is in, the file can be attached to records but not downloaded.
const scanned = await hb.files.waitUntilScanned(brief.id);
// scan_status: "clean" | "infected" | "failed"An infected file is moved to quarantine, cannot be downloaded or attached, and fires
file.infected. Subscribe to file.ready instead of polling if you'd rather be told.
Attach it to a record
A field of type file holds the id — and is a real foreign key to the catalog.
await hb.data.collections.create({
slug: "campaigns",
fields: [
{ name: "name", type: "string", required: true },
{ name: "brief", type: "file", on_delete: "restrict" }, // or "set_null" (default)
],
});
await campaigns.create({ name: "Spring refresh", brief: brief.id });Only a file that is uploaded, not deleted and not infected can be attached. With
restrict, deleting the file answers 409 while records point at it; with
set_null, the field clears when the file is finally purged.
Hand out a link
const { url, expires_at } = await hb.files.url(brief.id, { expiresIn: 300 });The URL goes straight to storage, carries the file name for the browser, and dies at
expires_at (default 10 minutes, at most an hour). Ask for a fresh one each time
someone needs it; there is nothing to revoke.
Delete, and get it back
await hb.files.delete(brief.id); // gone from lists and URLs; the object is kept
await hb.files.restore(brief.id); // within 30 daysAfter 30 days the object is dropped for good. Uploads announced but never completed are dropped after a day.
Attach it to a module record
Custom data fields are for your own collections; for records a module owns — a client, say — attach the file to the record in the location that holds it:
const link = await hb.files.attach(brief.id, {
targetType: "clients",
targetId: client.id,
locationId: location.id,
label: "contract",
});
const docs = await hb.files.list({ attached_to: `clients:${client.id}`, location_id: location.id });
// each file carries its `attachment` (label, when)
await hb.files.detach(brief.id, link.id); // the file stays in the catalogThe key must reach the location and the record must exist there; attaching the same file
twice returns the existing link. Links survive a soft delete of the file and go with it
when it's purged. Today clients is the attachable record type; modules opt in as they are
released.
Keys and scopes
files.read lists, reads and mints download URLs; files.upload announces,
completes and edits metadata; files.delete deletes and restores. See
Who can do what.
Limits
Per plan: the largest single upload (files.max_upload_bytes, 25 MB on
sandbox, 250 MB on production) and total storage (1 GB / 50 GB), both checked when an
upload is announced. Metadata is at most 4 KB.
Events
file.ready, file.infected, file.deleted, file.restored — see
Getting told when things happen.
Beta
Files are in beta: the shapes above are what we intend to keep, and anything that changes before general availability will be announced with a dated API version.