Document sharing
Documents are private by default. A document you create through /v1 or through an MCP tool is readable by your organization and nobody else until someone turns sharing on.
Turning sharing on is publishing. It mints a secret, and the resulting link is readable by anyone on the internet who has the URL — no sign-in, no organization membership, no expiry.
Creating estimates and bids
POST /v1/projects/{id}/estimates and POST /v1/projects/{id}/bids save the document private and return the signed-in path in documentUrl.
A REST caller opts into a public link with share: true on the request body:
curl -X POST https://api.exayard.com/v1/projects/{id}/bids \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"organizationId":"js79m7b3kfqd3vqzwx0kpcnryx7h2k4e","share":true}'
The TypeScript SDK takes the same flag on estimates.create and bids.create:
import { Exayard } from '@exayard/sdk'
// The estimator blocks for 30–60s, so raise the default 60s timeout.
const exa = new Exayard({ apiKey: process.env.EXAYARD_API_KEY!, timeoutMs: 180_000 })
const bid = await exa.bids.create('<projectId>', {
organizationId: 'js79m7b3kfqd3vqzwx0kpcnryx7h2k4e',
share: true
})
With share: true, documentUrl is the public path instead of the signed-in one. You can also share or unshare later with the endpoint below, so share: true is a convenience, never the only way.
The streaming sibling POST /v1/projects/{id}/estimates/generate persists no document, so it has no share flag.
The MCP tool schemas for those two operations have no share flag at all. The caller on that surface is a language model, and a model is not allowed to mint a public link on its own initiative. An agent that needs one asks the user and then calls set_document_sharing.
Turning sharing on and off
PATCH /v1/documents/{id}/share flips the switch. Scope: write:projects.
curl -X PATCH https://api.exayard.com/v1/documents/{id}/share \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"organizationId":"js79m7b3kfqd3vqzwx0kpcnryx7h2k4e","enabled":true}'
Enabling returns the link material:
{
"id": "kx71w9n2p4bd6vqzr0m3jscty5h8f2ea",
"name": "Bid for Elm St",
"shareEnabled": true,
"shareSecret": "9f3c1ab2d7e04c5f",
"sharePath": "/documents/kx71w9n2p4bd6vqzr0m3jscty5h8f2ea-9f3c1ab2d7e04c5f"
}
In the SDK that's documents.setSharing, which returns the same object typed as DocumentShare:
const share = await exa.documents.setSharing('<documentId>', {
organizationId: 'js79m7b3kfqd3vqzwx0kpcnryx7h2k4e',
enabled: true
})
// shareSecret and sharePath are optional on the type, because they are absent
// whenever sharing is off.
if (share.shareEnabled && share.sharePath) {
console.log(`https://exayard.com${share.sharePath}`)
}
Use documents.list({ organizationId }) or documents.listByProject(projectId, { organizationId }) to find a document id you didn't just generate.
Disabling returns shareEnabled: false and no shareSecret and no sharePath. The secret is what makes the link work, so we never echo it back for a document that isn't shared. Don't write a client that reads sharePath unconditionally.
A document id that belongs to another organization answers 404, exactly like an id that doesn't exist, so a 404 never tells you whether the id is real somewhere else.
Current state is readable on GET /v1/documents/{id}: shareEnabled and shareSecret come back on the document.
The secret survives a disable
Disabling revokes access but keeps the secret stored on the document. Re-enabling later reuses that same secret, so the old URL starts working again. This is deliberate: a customer who toggles sharing off and back on doesn't invalidate the link they already emailed their client.
The consequence for integrators: disabling is not the same as rotating. There is no rotate operation on this endpoint. If a link has leaked, treat the document itself as burned rather than assuming a disable/enable cycle produced a fresh URL.
Paths are relative
sharePath and documentUrl are app-relative paths, not absolute URLs, and always have been. Prepend the app host yourself:
const url = `https://exayard.com${res.sharePath}`
The MCP tool needs an explicit confirmation to enable
set_document_sharing wraps the same handler and adds one argument, confirmed.
enabled: truewithoutconfirmed: truewrites nothing. The tool returns{ requiresConfirmation: true, documentId, message }, and the message tells the model to ask the user before calling again withconfirmed: true.enabled: falseis ungated. Disabling narrows exposure, so it never needs a confirmation.
That asymmetry is a product rule, not an oversight. Enabling publishes; disabling un-publishes. Build agent flows expecting the first enable call to come back as a confirmation prompt rather than a result, and expect a second call after the user says yes.
REST callers don't see this gate. There, the enabled flag is itself the intent, because the caller is a human-written integration rather than a model.