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
organizationandpublicdocuments plus their ownprivateones. An organization admin sees every document in the organization. A document the caller cannot see answers404, not403. - 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 & path | Purpose |
|---|---|
GET /documents | List the library, filtered and paginated |
POST /documents | Create a markdown document |
POST /documents/upload | Upload a PDF (multipart) |
GET /documents/:id | One document, including its markdown body |
PATCH /documents/:id | Update title, body, assignees, or visibility |
DELETE /documents/:id | Delete the document, its file, and its attachments |
GET /documents/:id/download | 302 to a presigned attachment download |
GET /documents/:id/view | 302 to a presigned inline view |
POST /documents/:id/share | Publish behind a public link |
POST /documents/:id/revoke | Take the public link down |
POST /documents/:id/leads | Attach the document to leads |
DELETE /documents/:id/leads/:leadId | Detach one lead |
POST /documents/:id/property-listings | Attach the document to listings |
DELETE /documents/:id/property-listings/:listingId | Detach one listing |
GET /documents/by-listing/:listingId | Documents attached to one listing |
GET /crm/leads/:id/documents | Documents attached to one lead |
GET /public/documents/:slug | Keyless share resolver |
GET /public/documents/:slug/download | Keyless 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.
| Field | Type | Notes |
|---|---|---|
id | string | UUID |
title | string | Up to 200 characters |
kind | string | markdown or pdf |
visibility | string | private, organization, or public |
sizeBytes | number or null | PDF only |
createdBy | string | Clerk user id |
assigneeIds | string[] | Clerk user ids, up to 25 |
shareUrl | string or null | Non-null only while the link is live |
attachedLeadCount | number | |
attachedListingCount | number | |
createdAt / updatedAt | string | ISO-8601 |
body | string or null | Detail only. GFM markdown source, markdown kind only |
contentType | string or null | Detail only. PDF only |
shareRevokedAt | string or null | Detail only |
shareExpiresAt | string or null | Detail only. null means the link never expires |
attachedLeadIds | number[] | Detail only |
attachedListingIds | string[] | Detail only |
List documents
GET /documents
| Query | Type | Notes |
|---|---|---|
kind | string | markdown or pdf |
visibility | string | private, organization, or public. Narrows what the caller can already see; it never widens it |
assigneeId | string | Clerk user id pinned to the document |
q | string | Case-insensitive match on the title, up to 128 characters |
offset | number | Defaults to 0 |
limit | number | 1 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
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | Yes | 1 to 200 characters |
body | string | No | GFM markdown source, up to 1,000,000 UTF-8 bytes |
assigneeIds | string[] | No | Up 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.
| Part | Type | Required | Notes |
|---|---|---|---|
file | file | Yes | PDF only, up to 25 MB |
title | string | No | Falls back to the uploaded filename |
assigneeIds | string[] | No | Repeat 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:
| Field | Type | Notes |
|---|---|---|
title | string | 1 to 200 characters |
body | string | Markdown documents only. Replaces the whole body. 400 on a PDF |
assigneeIds | string[] | Up to 25 unique Clerk user ids. Replaces the list |
visibility | string | private 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.
| Field | Type | Required | Notes |
|---|---|---|---|
expiresAt | string | No | ISO-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.
| Field | Type | Required | Notes |
|---|---|---|---|
documentIds | string[] | No | Up 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:
code | When |
|---|---|
CRM_EMAIL_DOCUMENT_NOT_ATTACHABLE | The 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_LARGE | The 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.
| Status | Meaning |
|---|---|
404 | Unknown slug, or the owning organization is disabled or gone |
410 | The 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.
Related Articles
API Overview
Introduction to the Fondaro API: base URL, response format, error handling, and pagination.
Authentication
How to create, use, and revoke Fondaro API keys for programmatic access.
Documents (dashboard)
Write or upload documents once, then share them by link, attach them to leads and listings, and send them from the email composer.
MCP Server
Connect an MCP client with Fondaro OAuth or a scoped fdr_mcp_ API key.