Skip to content

Share documents with clients

import { Aside, Steps, Tabs, TabItem } from ‘@astrojs/starlight/components’;

The Documents tab is where you put anything the client should be able to grab: signed contracts, paid invoices, brand assets, the occasional one-off. Uploads land in a private S3 bucket; clients see finalized documents in their portal and can view them in-app (PDFs and images) or download them via short-lived presigned URLs.

For who sees what on the client side, see Client portal.

  • A Melbora Website you can administer
  • At least one file under 25 MB
  1. Open the Website in the portal and go to the Documents tab.

  2. Drop a file onto the upload zone, or click to browse.

    Both work. You can drop one file or several — each becomes its own document row.

  3. Pick a category for each file.

    Categories: contract, invoice, asset, other. Defaults to other if you don’t pick one. Categories show up as the filter tabs on the client’s Documents page.

  4. Wait for the upload to finalize.

    The portal mints a presigned S3 POST URL (5-minute TTL), uploads the file straight to S3, then calls the /complete endpoint which HEADs the object on S3 to confirm it landed and stamps the row with the authoritative byte length.

  5. The document appears in the client’s portal on their next load.

    Only finalized documents are visible to clients — the unfinalized window between create + complete is admin-internal.

The per-file cap is 25 MB. The cap is enforced at the metadata-create step, so oversized files fail before any bytes go to S3 — you get an immediate 400 with a clear message rather than a half-uploaded object.

For larger artifacts (raw video, design source trees), put them somewhere else and share a link instead. Melbora’s document storage is sized for the everyday paperwork, not media archives.

Every document carries one of four categories. Pick the one that matches what the client will be looking for:

CategoryWhat goes here
contractSigned agreements, SOWs, change orders.
invoiceIssued invoices, receipts.
assetBrand assets the client provided or you produced — logos, photography, copy decks.
otherEverything else.

Categories are display-only — they don’t change permissions or retention. They just give the client a sensible filter on a long document list.

Clicking a document’s filename — on either the admin Documents tab or the client’s Documents page — opens it in a modal viewer instead of forcing a download. Admins also get an explicit Open button on each row.

Supported types:

TypeHow it renders
PDF (application/pdf)Browser iframe (native PDF viewer).
Image (image/*)Inline <img>.
Anything else”Preview not available” placeholder with a Download button.

The viewer mints a presigned GET URL with ?disposition=inline, so the browser renders the response in-tab rather than triggering a download dialog. The URL is still scoped to the single object and still expires after 5 minutes (see Storage + permissions below) — inline disposition only changes the Content-Disposition header the GET responds with, not the auth model.

For everything that isn’t a PDF or an image, the modal falls through to a Download button — disposition: "attachment" (the default) mints a normal download URL.

  • Bucket — a private S3 bucket. No public-read ACL, no Bucket Policy that allows anonymous access. Direct S3 URLs return 403.
  • Reads — every download mints a presigned GET URL with a 5-minute TTL. Clients request a fresh URL each time they click download. URLs are scoped to the single object and expire on their own; you don’t need to revoke them.
  • Writes — only admins can upload (POST /websites/:id/documents), finalize (POST .../complete), rename, or delete. Clients have read-only access. They cannot upload, rename, or delete — those routes are adminOnly. The portal doesn’t even render those controls for non-admin sessions.
  • Unfinalized rows — clients never see them. If an upload is abandoned mid-flight, only admins see the orphaned row (so you can clean it up).

This corresponds to the routes under /websites/:id/documents (WebsitesDocumentsApi in the SDK; vantage websites documents in the CLI reference).

The CLI wraps the three-step flow (create slot → PUT to S3 → finalize) into one command:

Terminal window
vantage websites documents upload <websiteId> <filepath>

Example:

Terminal window
vantage websites documents upload wb_01J7XB4MQH ./contracts/sow-2026-q2.pdf

The CLI infers the file name, size, and MIME type from the local file, mints the upload URL, PUTs the bytes to S3, then calls /complete to finalize the row. On success it prints the documentId.

To set a category or rename the displayed name, pass the matching flag (e.g. --category contract --name "SOW Q2 2026"); see the CLI reference for the full list.

Terminal window
# List documents on a website.
vantage websites documents list <websiteId>
# Print a 5-minute presigned download URL (paste into a browser or curl).
vantage websites documents download <websiteId> <documentId>
# Delete a document — removes both the DDB row and the S3 object.
vantage websites documents delete <websiteId> <documentId>

If you’re scripting outside the CLI, the three-step flow is exposed as plain methods:

import { VantageClient } from "@vantageconnections/sdk";
import { readFile, stat } from "node:fs/promises";
const vc = new VantageClient({ token: process.env.VANTAGE_TOKEN! });
const filePath = "./contracts/sow-2026-q2.pdf";
const fileName = "sow-2026-q2.pdf";
const bytes = await readFile(filePath);
const sizeBytes = (await stat(filePath)).size;
// 1. Reserve a slot + mint a presigned S3 POST URL.
const slot = await vc.websites.documents.create("wb_01J7XB4MQH", {
fileName,
mimeType: "application/pdf",
sizeBytes,
name: "SOW Q2 2026",
category: "contract",
});
// 2. POST the file directly to S3 (multipart/form-data with the
// presigned fields). Use whichever HTTP client you like — fetch
// + FormData works in Node 18+.
const form = new FormData();
for (const [k, v] of Object.entries(slot.uploadFields)) form.append(k, v);
form.append("file", new Blob([bytes]), fileName);
const s3Res = await fetch(slot.uploadUrl, { method: "POST", body: form });
if (!s3Res.ok) throw new Error(`S3 upload failed: ${s3Res.status}`);
// 3. Finalize — Melbora HEADs the object and stamps the row.
const { document } = await vc.websites.documents.complete(
"wb_01J7XB4MQH",
slot.document.documentId
);
console.log("Uploaded:", document.documentId, document.sizeBytes, "bytes");

downloadUrl() mints the 5-minute presigned GET. Pass disposition: "inline" when you want the browser to render the response in-tab instead of triggering a download — that’s the variant the in-app viewer uses for PDFs and images. Default disposition is attachment.

// Attachment (default) — browser shows the save dialog.
const dl = await vc.websites.documents.downloadUrl(
"wb_01J7XB4MQH",
"doc_01J7XXX",
);
// Inline — feed straight into an <iframe src> or <img src>.
const preview = await vc.websites.documents.downloadUrl(
"wb_01J7XB4MQH",
"doc_01J7XXX",
{ disposition: "inline" },
);

Both variants are 5-minute single-object presigned URLs — the only difference is the Content-Disposition header S3 emits.

SymptomLikely cause
400 must be ≤ 26214400 bytes on createFile is over the 25 MB cap. Host it elsewhere and share a link.
400 Upload not visible on S3 yet — wait a moment and retry on completeThe S3 PUT hasn’t propagated yet, or the PUT actually failed. Retry; if it still fails, re-check the upload step’s response status.
Client doesn’t see a document you just uploadedThe /complete step never ran — the row is stuck as finalized: false. Re-run the upload, or call complete() directly with the documentId.
Download URL returns 403 after a few minutesURLs expire 5 minutes after issue. Mint a fresh one.