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.

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.