Documents

REST endpoints for the organization document library: CRUD, PDF upload, presigned downloads, share links, lead and listing attachments, and the keyless public resolver.

Overview

The Documents API stores an organization's markdown documents and uploaded PDFs. All authenticated endpoints are under /documents, require a Clerk bearer token, and resolve the organization from the request. The organization is never a path or body parameter, so a caller only ever reaches their own library.

Two rules govern every response:

  • Read is visibility. A member sees organization and public documents plus their own private ones. An organization admin sees every document in the organization. A document the caller cannot see answers 404, not 403.
  • Write is ownership. Changing, sharing, revoking, or deleting a document is limited to its creator and organization admins. Attaching to a lead or a listing is deliberately looser: it needs read on the document plus access to the lead or listing, so one member can author a guide the whole organization sends out.

Every endpoint on this page, reads included, requires an active subscription. An organization without one receives 403 with code: "SUBSCRIPTION_REQUIRED" on every route, and its public share links stop resolving (404) until it subscribes again. Nothing is deleted; subscribing restores the library and every link as it was.

Markdown bodies are stored in Postgres. PDFs are stored private in object storage and are only ever reachable through a short-lived presigned URL minted behind authorization. The storage key never appears in a response body.

Endpoints

Method & pathPurpose
GET /documentsList the library, filtered and paginated
POST /documentsCreate a markdown document
POST /documents/uploadUpload a PDF (multipart)
GET /documents/:idOne document, including its markdown body
PATCH /documents/:idUpdate title, body, assignees, or visibility
DELETE /documents/:idDelete the document, its file, and its attachments
GET /documents/:id/download302 to a presigned attachment download
GET /documents/:id/view302 to a presigned inline view
POST /documents/:id/sharePublish behind a public link
POST /documents/:id/revokeTake the public link down
POST /documents/:id/leadsAttach the document to leads
DELETE /documents/:id/leads/:leadIdDetach one lead
POST /documents/:id/property-listingsAttach the document to listings
DELETE /documents/:id/property-listings/:listingIdDetach one listing
GET /documents/by-listing/:listingIdDocuments attached to one listing
GET /crm/leads/:id/documentsDocuments attached to one lead
GET /public/documents/:slugKeyless share resolver
GET /public/documents/:slug/downloadKeyless presigned PDF download

The document object

GET /documents returns the summary shape. Everything that returns a single document returns the detail shape, which is the summary plus the last six fields.

FieldTypeNotes
idstringUUID
titlestringUp to 200 characters
kindstringmarkdown or pdf
visibilitystringprivate, organization, or public
sizeBytesnumber or nullPDF only
createdBystringClerk user id
assigneeIdsstring[]Clerk user ids, up to 25
shareUrlstring or nullNon-null only while the link is live
attachedLeadCountnumber
attachedListingCountnumber
createdAt / updatedAtstringISO-8601
bodystring or nullDetail only. GFM markdown source, markdown kind only
contentTypestring or nullDetail only. PDF only
shareRevokedAtstring or nullDetail only
shareExpiresAtstring or nullDetail only. null means the link never expires
attachedLeadIdsnumber[]Detail only
attachedListingIdsstring[]Detail only

List documents

GET /documents

QueryTypeNotes
kindstringmarkdown or pdf
visibilitystringprivate, organization, or public. Narrows what the caller can already see; it never widens it
assigneeIdstringClerk user id pinned to the document
qstringCase-insensitive match on the title, up to 128 characters
offsetnumberDefaults to 0
limitnumber1 to 50, defaults to 20
curl -G https://api.fondaro.com/documents \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "kind=pdf" \
  --data-urlencode "q=guide" \
  --data-urlencode "limit=20"
{
  "items": [
    {
      "id": "3f1c0d0e-0000-4000-8000-000000000000",
      "title": "2026 Property Guide",
      "kind": "pdf",
      "visibility": "public",
      "sizeBytes": 2214592,
      "createdBy": "user_2abc",
      "assigneeIds": [],
      "shareUrl": "https://www.fondaro.com/d/9pQ2rB7wKx1vT0sN4mH6cA",
      "attachedLeadCount": 3,
      "attachedListingCount": 0,
      "createdAt": "2026-08-01T09:12:00.000Z",
      "updatedAt": "2026-08-20T14:03:00.000Z"
    }
  ],
  "total": 1,
  "offset": 0,
  "limit": 20
}

Results are ordered by updatedAt descending.

Create a markdown document

POST /documents

FieldTypeRequiredNotes
titlestringYes1 to 200 characters
bodystringNoGFM markdown source, up to 1,000,000 UTF-8 bytes
assigneeIdsstring[]NoUp to 25 unique Clerk user ids
curl -X POST https://api.fondaro.com/documents \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "2026 Property Guide",
    "body": "# 2026 Property Guide\n\nWhat buyers ask us most."
  }'

Returns the detail shape. A new document starts at private visibility.

Upload a PDF

POST /documents/upload, multipart/form-data.

PartTypeRequiredNotes
filefileYesPDF only, up to 25 MB
titlestringNoFalls back to the uploaded filename
assigneeIdsstring[]NoRepeat the field, or send a JSON array
curl -X POST https://api.fondaro.com/documents/upload \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@guide.pdf;type=application/pdf" \
  -F "title=2026 Property Guide"

The declared MIME type is caller-controlled and proves nothing, so the service also sniffs the leading %PDF- bytes. A file that fails either check is rejected with 400 and nothing is stored. There is no presigned-PUT path: uploads always go through the API so validation stays server-side.

Read, update, and delete

GET /documents/:id returns the detail shape.

PATCH /documents/:id accepts any subset of:

FieldTypeNotes
titlestring1 to 200 characters
bodystringMarkdown documents only. Replaces the whole body. 400 on a PDF
assigneeIdsstring[]Up to 25 unique Clerk user ids. Replaces the list
visibilitystringprivate or organization only

public is deliberately not patchable. It is reachable only through POST /documents/:id/share, so a public row always has a live link behind it. Patching visibility on a document that is currently public is refused: revoke the link first.

DELETE /documents/:id returns { "deleted": true }. It removes the row, its attachment records, and the document's stored objects. Any share link stops resolving.

Downloads

GET /documents/:id/download and GET /documents/:id/view both answer 302 with a five-minute presigned URL and Cache-Control: no-store. Download forces an attachment disposition with a filename derived from the title; view is the inline twin used by the dashboard preview. The redirect is minted only after the document row is joined to the caller's organization and passes the visibility check.

# -L follows the redirect; the signed URL is short-lived and caller-specific.
curl -L -o guide.pdf https://api.fondaro.com/documents/$DOC_ID/download \
  -H "Authorization: Bearer $TOKEN"

Both routes are PDF-only. A markdown document has no stored file and answers 400.

Share links

POST /documents/:id/share sets visibility to public, mints an unguessable slug, and returns the detail shape with shareUrl populated.

FieldTypeRequiredNotes
expiresAtstringNoISO-8601 instant in the future. Omit for a link that never expires

The call is idempotent while the current link is live: sharing twice returns the same URL, so a dialog that shares on open never invalidates an address somebody already sent. An expiresAt in the past is rejected with 400 rather than minting a link that is dead on arrival.

curl -X POST https://api.fondaro.com/documents/$DOC_ID/share \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

POST /documents/:id/revoke stamps the revocation and drops visibility back to organization, so the team keeps the document while the public link dies. It is idempotent: a second revoke keeps the original timestamp.

Sharing again after a revoke mints a different slug. Revoke is a real kill switch, so addresses already sent stay dead. The same applies after an expiry lapses. Clients that cached a shareUrl must re-read the document after a revoke and re-share.

Attachments

POST /documents/:id/leads takes { "leadIds": number[] }, up to 50 unique ids. Every id must clear the caller's own lead visibility, so a document is never a side door onto a lead the caller cannot open. An unreachable id answers 404 and nothing is written. Inserts are idempotent and keep the first attachment record.

DELETE /documents/:id/leads/:leadId detaches one lead and is a clean no-op when the row is already gone.

POST /documents/:id/property-listings takes { "listingIds": string[] }, up to 50 unique UUIDs. Listings live in a separate database, so the column carries no foreign key and every id is proven organization-owned before a single row is written. A partial attach never happens. Detach deliberately skips that check, so a listing deleted upstream can still be detached.

All four endpoints return the document detail shape.

curl -X POST https://api.fondaro.com/documents/$DOC_ID/leads \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"leadIds": [4821, 4822]}'

Reverse lookups

GET /crm/leads/:id/documents and GET /documents/by-listing/:listingId both return { "documents": [...] } using the attachment shape: id, title, kind, visibility, sizeBytes, createdBy, attachedBy, attachedAt, and updatedAt. They are ordered most recently attached first.

An attachment is not a grant. Both lists re-apply the document visibility clause, so a private document another member attached stays absent for everyone but its creator and the organization admins.

{
  "documents": [
    {
      "id": "3f1c0d0e-0000-4000-8000-000000000000",
      "title": "2026 Property Guide",
      "kind": "pdf",
      "visibility": "public",
      "sizeBytes": 2214592,
      "createdBy": "user_2abc",
      "attachedBy": "user_2def",
      "attachedAt": "2026-08-20T14:05:00.000Z",
      "updatedAt": "2026-08-20T14:03:00.000Z"
    }
  ]
}

Attaching a document to a lead also appears in that lead's timeline as a derived document-attached entry. No separate event row is written.

Sending a document in a lead email

POST /crm/leads/:id/emails accepts an optional documentIds array of up to three unique document UUIDs. Each one becomes a real email attachment.

FieldTypeRequiredNotes
documentIdsstring[]NoUp to 3 unique UUIDs. PDF documents only

Every id is re-checked against the caller's document visibility first, so an invisible or foreign document answers 404, exactly as a missing one does. Two refusals then carry a machine-readable code in a 422 body, both naming the documentId the sender would blame:

codeWhen
CRM_EMAIL_DOCUMENT_NOT_ATTACHABLEThe id is a markdown document, or a PDF with no stored file. Link to it instead by putting its shareUrl in the body
CRM_EMAIL_ATTACHMENTS_TOO_LARGEThe set goes over the 10 MB combined cap

Both run before the message reaches the mail provider. Declared sizes are checked before anything is downloaded, then the real bytes are re-totalled after fetching.

The stored email records which documents went out under contentReferences.documentAttachments, so the lead timeline can show them on the sent message.

curl -X POST https://api.fondaro.com/crm/leads/4821/emails \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "senderName": "Sofia Andersson",
    "senderEmail": "sofia@example.com",
    "subject": "The guide I mentioned",
    "bodyText": "<p>Here it is.</p>",
    "documentIds": ["3f1c0d0e-0000-4000-8000-000000000000"]
  }'

Public resolver (keyless)

GET /public/documents/:slug needs no credential. The slug is the entire authorization. The route is rate limited to 60 requests per minute per IP.

curl https://api.fondaro.com/public/documents/9pQ2rB7wKx1vT0sN4mH6cA
{
  "slug": "9pQ2rB7wKx1vT0sN4mH6cA",
  "title": "2026 Property Guide",
  "kind": "pdf",
  "body": null,
  "viewUrl": "https://…presigned…",
  "sizeBytes": 2214592,
  "updatedAt": "2026-08-20T14:03:00.000Z"
}

body carries the GFM markdown source for a markdown document. viewUrl is a five-minute presigned inline URL for a PDF, meant to be consumed immediately by the page that just loaded. It must never be emailed or stored: the durable address is always https://www.fondaro.com/d/{slug}.

The response deliberately says nothing about the organization, the author, visibility, or attachments. A share link reveals the document, never the organization around it.

StatusMeaning
404Unknown slug, or the owning organization is disabled or gone
410The link existed and no longer resolves

A 410 body carries a machine-readable code of revoked or expired, so a viewer can say which happened:

{
  "statusCode": 410,
  "message": "This document link has been revoked by the agent.",
  "code": "revoked"
}

Public document existence is private tenant state, which is why a disabled organization collapses to the same 404 as an unknown slug.

GET /public/documents/:slug/download answers 302 to a five-minute presigned attachment download for a shared PDF, and walks the same resolution ladder on every hit. A link revoked between page load and click gets a 410, not a file. A markdown document answers 400: it has no file.

MCP and assistant access

The same library is reachable from Fondaro MCP under the documents:read and documents:write scopes, and from Ask Fondaro, where every document write is a proposal a person approves. PDF upload and document deletion are exposed on neither surface: an MCP request has no file transport, and deletion stays a dashboard action.