What File Manager is#
A file library your customers, members or applicants use on your website — with the documents filed against the CRM records they belong to, rather than in a folder tree nobody maintains.
A member signs in to your site and sees the files that are theirs: attached to their company, their ticket, their project, or to a record type you chose. They upload, and the upload lands attached to the right records automatically. Nothing about it requires anyone to have a HubSpot seat.
| Kind | Public HubSpot app (OAuth), installed per portal |
| Where it runs | A module on your own website pages, behind whatever membership you already use |
| Stores files in | More than one place, by rule — see Storage |
| Stores metadata in | A custom object on your own portal, p_filerecords, provisioned automatically at install |
| Also available as | A component another app can render inside itself — the Portal app shows its file UI without holding a copy of it |
| How to get it | Not self-service. Installs are allowlisted — ask for one |
What it does#
Files follow records, not folders. A file is attached to CRM records — a company, a contact, a ticket, a project, or a custom object type you nominate — and who can see it follows from that. There is no permission tree to maintain in parallel with your CRM, because the CRM is the tree.
One upload, several records. A member attaches a document once and it lands against every record it belongs to — an invoice against the company and the ticket, a certificate against the contact and the project. This is one component, not a workflow.
Uploads arrive where you decided. Routing rules decide the backend, and mapping rules decide the records — both configured once by an administrator, not chosen by the member at upload time.
Downloads stream. A download is a real streamed response with a filename, not a base64 payload the browser has to reassemble, which is what lets it handle files far larger than a page can hold in memory.
Storage that spans more than one place#
The point of the multi-source design is that "where the bytes live" is a rule, not a decision anyone makes per file. A member sees one library.
| Backend | Status |
|---|---|
| HubSpot Files | Live. The default for ordinary documents |
| Private file server | Live. Takes what HubSpot should not or cannot — video by extension, and anything above the routing threshold |
| Google Drive | Live. A folder on the customer's own Drive, swept on a schedule as well as on demand |
| SharePoint | Built, and switched off everywhere. Written against a documented Microsoft Graph contract and a fixture — no real tenant has answered any of it yet. It is not a supported option until credentials arrive and the go-live measurements are taken. We would rather say that than ship it quietly enabled |
Adding another backend is a bounded piece of work rather than a rewrite: the registry, the sync engine and the download dispatcher are generic, and a new source supplies the provider-specific parts.
Size: the server refuses anything above 4 GB outright. What a given portal actually accepts is usually lower — computed per portal from its own configuration — and what the upload area advertises to a member is lower still. Treat the advertised figure as a hint and the server's answer as the truth.
What it does not do#
- It is not a document management system. No versioning, no check-out, no approval workflow, no retention policy engine.
- It is not a HubSpot seat replacement for staff. It is the member-facing side; your team works in the CRM.
- It does not sync a whole Drive. A configured folder, not a whole account.
- It does not do previews in-app. A file is downloaded, not rendered inline — deliberately, because the shortcut that makes preview cheap is the one that breaks downloads (see Conventions).
Identity — how a member is bound to their files#
Every request arrives on behalf of a proven contact, not a claimed one.
The page tells the app who the visitor is; the app verifies that claim against the CRM before minting a signed session token, and every contact-scoped route reads the contact from that token. An id in a request body is never taken as proof of identity — if one is supplied, the token's contact overrides it. That is what makes "show me the files for contact 123" safe to expose to a browser.
Two consequences worth knowing before you design against it:
- Enforcement is per install. A portal's posture depends on its own configuration and on having a session-aware client deployed. Send the token whenever you have one, and never treat its absence as optional.
- Downloads carry their own credential. A browser navigation cannot attach a header, so a download URL is minted on demand, short-lived, and bound to the specific file. The capability is the credential, and it is cryptographically distinct from a session token — neither verifies as the other, which is what makes its ten-minute life real rather than decorative.
The deeper trust boundary — what the session proves and what it does not — is a deployment-review conversation rather than a published one. Ask for it.
Endpoint index#
Generated from the same policy manifest the service's own route-coverage test asserts against, so it cannot drift from what is running.
Member is the bulk of it — a proven contact acting on their own files. Scoped routes are the ones a browser reaches without a session header: a download link, an upload already in progress, an OAuth callback. Each carries a signed capability bound to that one thing, which is what lets a plain navigation or a redirect work without ever being a way in to anything else.
35 endpoints — 22 public, 8 member, 5 scoped.
| Endpoint | Surface | Auth required |
|---|---|---|
GET /apps/filemanager/license-status | Public | None |
GET /apps/filemanager/settings/companies | Public | Portal + licence |
GET /apps/filemanager/settings/records | Public | Portal + licence |
GET /apps/filemanager/settings/records/:objectType/:recordId/audience | Public | Portal + licence |
GET /apps/filemanager/settings/sources | Public | Portal + licence |
GET /apps/filemanager/settings/sources/googledrive/drives | Public | Portal + licence |
GET /apps/filemanager/settings/sources/googledrive/folders | Public | Portal + licence |
GET /apps/filemanager/settings/sources/sharepoint/folders | Public | Portal + licence |
GET /apps/filemanager/settings/sources/sharepoint/libraries | Public | Portal + licence |
GET /apps/filemanager/settings/status | Public | Portal + licence |
GET /apps/filemanager/settings/target-types | Public | Portal + licence |
GET /apps/filemanager/settings/uploads | Public | Portal + licence |
POST /apps/filemanager/session | Public | Portal + licence |
POST /apps/filemanager/settings/resync-assert-secret | Public | Portal + licence |
POST /apps/filemanager/settings/simulate-routing | Public | Portal + licence |
POST /apps/filemanager/settings/sources/:source/sync | Public | Portal + licence |
POST /apps/filemanager/settings/sources/:source/verify | Public | Portal + licence |
POST /apps/filemanager/settings/sources/googledrive/connect-url | Public | Portal + licence |
POST /apps/filemanager/settings/sources/sharepoint/resolve-site | Public | Portal + licence |
PUT /apps/filemanager/settings/routing | Public | Portal + licence |
PUT /apps/filemanager/settings/sources/:source | Public | Portal + licence |
PUT /apps/filemanager/settings/uploads | Public | Portal + licence |
POST /apps/filemanager/associable-targets | Member | Portal + contact session |
POST /apps/filemanager/delete-files | Member | Portal + contact session |
POST /apps/filemanager/download-files | Member | Portal + contact session |
POST /apps/filemanager/download-url | Member | Portal + contact session |
POST /apps/filemanager/fetch-files | Member | Portal + contact session |
POST /apps/filemanager/search-files | Member | Portal + contact session |
POST /apps/filemanager/upload-file | Member | Portal + contact session |
POST /apps/filemanager/upload-init | Member | Portal + contact session |
GET /apps/filemanager/download/:capability | Scoped | A signed capability in the URL, which is the whole credential |
GET /apps/filemanager/google/callback | Scoped | A signed capability in the URL, which is the whole credential |
GET /apps/filemanager/upload-status/:uploadId | Scoped | Bound to the upload in progress |
POST /apps/filemanager/upload-chunk/:uploadId | Scoped | Bound to the upload in progress |
POST /apps/filemanager/upload-finalize/:uploadId | Scoped | Bound to the upload in progress |
Base URL: https://api.worksby.design/apps/filemanager/. Every request carries portalId; contact-scoped routes additionally carry the session token as a header.
Data model#
One custom object, p_filerecords, created on your portal automatically during install. It holds the metadata — name, size, type, when and by whom — and the associations to whatever the file belongs to. The bytes live in whichever backend the routing rules chose.
One field is worth understanding because it is load-bearing: the stored path tells you which backend holds the file, without a separate column to keep in step. A relative path means the private file server; a full URL means HubSpot. A schema field that could disagree with reality was deliberately not added.
Object type ids differ per portal — resolve them at runtime from the schema list, and use the p_ prefix form of the name rather than a portal-stamped one.
Surfaces#
| Surface | Where | What it is |
|---|---|---|
| File library | A module on your website pages | The member-facing library — browse, search, upload, download |
| Upload area | Same module, or standalone | Drag and drop, with the record targets applied by rule |
| Settings | HubSpot, in the app's settings | Sources, routing, mapping targets, sync schedule, and a security and storage status panel |
| Rendered inside another app | Wherever that app runs | The library can render inside the Portal app at runtime — one implementation, two products, no fork |
Integration seams#
| With | What crosses |
|---|---|
| Portal | Portal renders File Manager's file UI at runtime rather than holding a copy of it. That is why a fix here reaches both products at once, and why Portal ships no file code of its own |
| Your website's membership | Whatever already authenticates your visitors. File Manager takes the identity your page asserts, verifies it against the CRM, and works from there — it does not want to own your login |
Conventions and gotchas#
- Never turn a download into a redirect to a signed CDN URL. HubSpot's signed URLs carry no
Content-Disposition, so the browser renders PDFs and images inline instead of saving them, and the bug looks like a UI problem rather than a routing one. Stream it and set the header yourself. This one has cost time on more than one app. - The advertised upload limit is UI, the server's is truth. Three numbers are in play — the hard ceiling, the portal's effective maximum, and what the dropzone displays. Only the first is guaranteed; validate against the server's answer, not the label.
- A scope added to the app does not reach a portal until it reinstalls. Until then the affected calls fail in ways that read like unrelated bugs. Check the install state before debugging the feature.
- Storage routing is one shared module, used by File Manager and Portal alike, so a routing change is a two-product change. That is deliberate — two copies of a routing policy is how files start landing in two different places.
- Resolve association type ids by label at runtime. They are per portal, and a hardcoded id fails silently rather than loudly.
Verifying it works#
Deploying is not verifying. On the portal you care about, in order:
- Check the app reports itself licensed and installed with no outstanding scope changes.
- Load the module on a page as a signed-in member and confirm the library lists that member's files — and only theirs.
- Sign in as a different member and confirm the list changes. This is the check that actually tests the identity binding; step 2 alone passes just as happily when everyone sees everything.
- Upload a file and confirm it appears against every record the mapping rules should have attached it to — not just the first.
- Download it and confirm it saves with the right filename rather than opening in a tab.
- Upload something large enough to route to the other backend and repeat step 5. Routing is the part that is right for the common case and wrong for the exception.