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: w@reus.ie |
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 easy 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.
15 endpoints — 2 public, 8 member, 5 scoped.
| Endpoint | Surface | Auth required |
|---|---|---|
GET /apps/filemanager/license-status | Public | None |
POST /apps/filemanager/session | 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 easy to get right for the common case and wrong for the exception.