Skip to main content

Use the customer portal API

These Vercel routes serve the production customer workspace and staff controls. Every route requires a valid SABLE session and derives organization and person identity from that session.

Client access comes from client_memberships. Admins and leads can access organization clients and manage access. Consultants are limited to active or current projects assigned through project_members. The API never trusts browser-supplied identity or capability fields.

Read a workspace​

GET /api/client-portal?client_id=client_uuid&view=overview&cursor=opaque_cursor&limit=25

Supported query parameters are:

ParameterValuesPurpose
client_idUUIDSelect an authorized workspace
viewthis_week, overview, plan, reviews, requests, resourcesSelect the paged collection
idUUIDFetch one authorized published entity regardless of its page
cursorOpaque stringContinue deterministic pagination
limit1 through 100Set the page size
sinceISO 8601 timestampBound published changes
queryTextSearch every customer-visible category at one cursor

The response contains viewer, client, clients, workspaces, projects, contacts, capabilities, summary, queues, changes, milestones, milestone_options, reviews, requests, resources, and page, and, except on load-more pages, the This week extras enabledViews, landingView, links, recentMeetings, recentEmails (up to ten shared emails, newest first: id, title, occurred_at, summary, full_text (the full email the team approved, or null), sender_name and attachments with id, title and downloadable; never an address or the stored body), weeklyUpdates (up to 30 published updates, newest first), latestUpdate (the first of them), customerUploads, and thisWeekCards, plus the tab and This week fields enabledViews, landingView, links, recentMeetings (up to ten shared meetings with summary; source_url only when the recording link is shared), and latestUpdate. A client with no policy row gets the default five tabs and an overview landing view; links is empty only until the Essentials migration is applied. summary.last_visited_at is the visit immediately before the request, and summary.current_visited_at records the current request.

Without client_id, a customer receives the first authorized workspace plus metadata-only choices for switching. Staff receive the metadata-only launcher. A mutation reloads its exact item by ID, so an idempotent replay returns a complete receipt even when the item no longer appears on the first page.

Each launcher row reports readiness.shared_content_count, shared_resources, published_updates, deliverables, milestones, requests, membership_count, and ready. shared_content_count is the sum of the current published customer content categories, not the size of the internal source inventory.

loadPortalWorkspace(admin, viewer, options) is the canonical server reader. Set projection: "released" for customer-facing assistance. That projection preserves viewer authorization, enforces publication gates, and excludes internal request events, storage paths, and raw source bodies.

Mutate a request​

Send request actions to one route:

POST /api/client-portal
Content-Type: application/json

Every mutation accepts idempotency_key. Repeating the same operation by the same actor returns its original receipt. Reusing the key with different content returns HTTP 409.

Supported actions are:

  • create_request: requires client_id, title, and idempotency_key; accepts project_id, body, and due_at
  • reply_request: requires client_id, request_id, expected_revision, and idempotency_key; accepts body and up to 20 attachment_ids; at least one of body or attachments must be present
  • update_request: requires client_id, request_id, expected_revision, and idempotency_key; accepts status, assignee_person_id or assigned_to, next_response_side, due_at, and resolution_summary; staff scope is required
  • internal_note: requires client_id, request_id, body, expected_revision, and idempotency_key; staff scope is required

Request states are open, in_review, waiting_on_client, changes_requested, resolved, and closed. Setting status to resolved requires resolution_summary. Client callers can create customer-visible replies only.

Responses contain the shaped request, immutable receipt, and new revision. A stale expected_revision returns HTTP 409 with current state.

Prepare, complete, and download request files​

POST /api/portal-attachments uses a two-step private upload:

  1. Send action: "prepare" with client_id, request_id, file_name, content_type, size_bytes, and visibility.
  2. Upload the bytes to the returned signed Storage URL.
  3. Send action: "complete" with attachment_id.
  4. Bind the ready attachment_id in reply_request.

The server accepts PDF, PNG, JPEG, WebP, DOCX, and plain text files up to 10 MiB. It checks the extension, declared type, byte count, file signature, safe DOCX envelope, and absence of macros or embedded programs. Each request can retain 50 nondeleted files.

GET /api/portal-attachments?id=attachment_uuid returns a download URL that expires after 60 seconds. An unpublished attachment is visible only to its uploader. A ready attachment becomes visible to authorized participants only after a request event binds it.

Download a shared document​

GET /api/portal-files?download=<document_id>&client_id=<client_id> returns { url, file_name, expires_in: 60 }: a signed Storage URL for the documents bucket that downloads the file (attachment disposition, named from the shared title plus the stored extension) and expires after 60 seconds (20 Oct 2026).

The server resolves the viewer's client scope, then calls list_portal_resources for that one id over the viewer's own active, current projects; the id must come back as a document. It then re-reads the documents row and refuses it unless it is in the viewer's org and scope, not internal_only, not restricted, library_state = 'active', not an unfinished portal upload, and has a storage_path. Every refusal is 404 with the same message, so another client's file is indistinguishable from a missing one; a database or Storage failure is 503. Staff, including View as customer, follow the same rule. The workspace payload marks such documents with downloadable: true in resources.

Upload files to a project Library​

POST /api/portal-files lets a customer add files to one of their current projects (20 Oct 2026). Uploads need no approval: a file that passes the checks shows in Resources at once.

  1. Send action: "prepare" with client_id, project_id, file_name, and size_bytes. The server creates a pending documents row with no release flags and returns a signed Storage URL.
  2. Upload the bytes with PUT to that URL, using the returned headers.
  3. Send action: "complete" with document_id.

The caller must be a client member of the client (can_reply) or staff with access, and the project must be one of the caller's active, current projects of that client. For customers, the client's portal_policies.customer_uploads must be on; it is on by default, including for a client with no policy row. Staff are not bound by it.

The server accepts PDF, DOCX, XLSX, PPTX, CSV, plain text, PNG, JPEG, and WebP files up to 15 MB. The extension decides the type; the browser's declared type is ignored. Macro-enabled files (.xlsm, .docm, .pptm and similar) are refused, and on complete the bytes are checked for the declared size, file signature, a bounded Office envelope, macro projects, ActiveX, OLE objects, and macro-enabled content types. Text and CSV must be UTF-8 with no NUL bytes. A file that fails is quarantined: its row is excluded from the Library and the stored object is removed.

Only the uploader can complete an upload, and a repeated complete returns idempotent_replay: true. On success the row gets released_to_client, portal_visible, and release_status: "released" in metadata, indexing is queued as sable.document.index, and customer uploads enqueue a document.uploaded notice to staff_leads in the releases category. Neither indexing nor the notice can fail an upload that has already been stored.

Rows use source_system: "portal_upload" and category: "other". Customers never write documents directly; only this server route does, with the service role.

Browse and publish customer content​

Staff browse publication candidates and readiness through one route:

GET /api/portal-publications?client_id=client_uuid&kind=all&limit=25

The route accepts kind as all, document, meeting, email, or draft (all does not include emails); optional project_id, query, cursor, limit, and id filters; and requires staff scope. Admins and leads can browse every active or current project for the client. Consultants can browse only active or current projects assigned through project_members and cannot publish.

The response contains:

  • client and capabilities.can_browse or capabilities.can_publish
  • Exact counts for available and shared documents, available and shared meetings, draft totals, release-ready drafts, shared resources, published updates, memberships, milestones, requests, and deliverables
  • Paged items with identity, kind, project, title, summary, source URL, update time, publication state, origin, revision, blockers, and row-level actions
  • An optional detail item with bounded source_preview and curated publication metadata
  • page.next_cursor, page.has_more, and page.limit

The staff preview never returns a document body or meeting transcript. A customer projection receives the curated publication title, summary, and optional URL only. Meetings never fall back to a source summary, and documents never expose their body.

publication_origin is explicit for a curated resource share, deliverable when an active released deliverable grants document visibility, and null when the item is not published.

Admins and leads publish or withdraw one resource with POST /api/portal-publications:

{
"action": "publish_resource",
"client_id": "client_uuid",
"source_id": "source_uuid",
"kind": "document",
"title": "Launch readiness brief",
"summary": "Decisions and next actions approved for the customer workspace.",
"include_source_link": true,
"expected_revision": 0,
"idempotency_key": "stable_operation_key",
"reason": "Approved for the customer workspace"
}

kind is document, meeting, or email. An email is published with include_source_link: false (it has no link) and an optional full_text (at most 50,000 characters; blank means summary only), through publish_portal_email; action: "suggest_email_summary" with client_id and source_id returns summary (2–4 sentences, or the start of the message when the model fails), generated, message, earlier and, once shared, published (summary, full_text), for exactly the people who may publish that email. Publishing it also publishes its shareable attachments as documents (not restricted, contract or SOW, or Internal); withdrawing it takes back the ones it shared. The email's detail item.source_preview adds sender_name (a display name, never an address) and attachments (id, title, shareable, shared). Publishing validates exact organization, client, and active-project scope. It writes a curated metadata snapshot, immutable publication event, audit row, and notification outbox event atomically.

Withdrawal uses action: "withdraw_resource" with the same resource identity, expected_revision, idempotency_key, and reason. It hides an explicit share unless an active immutable released deliverable still links the document. A stale revision or reused key with different content returns HTTP 409.

An email publication is also withdrawn by the database, without this route, when the email is dismissed (sources.rejected_at set) or its project_id changes, including to null (20261020170000_portal_email_reject_withdraw.sql). The trigger withdraws the email and the attachments it shared that are still published for the same client and project, writes a publication event (receipt.automatic: true, receipt.cause email_dismissed, email_unfiled or email_moved) and an audit row, and queues no customer notice. After a move to a filed project, the withdrawn row follows the email to that project and its client, so it can be published again there.

GET /api/inbound-emails?type=email (admin only) adds client_id (the email's project's client) and, for filed emails, portal: { published, revision } from one query over the page's ids, so Inbound can show Publish to portal or On portal and Withdraw. In the Approvals list an Email row publishes through the same dialog (Client portal).

Publish a direct customer update through the same route:

{
"action": "publish_update",
"client_id": "client_uuid",
"project_id": "project_uuid",
"title": "Launch timing confirmed",
"body": "The team confirmed the launch window and assigned the remaining checks.",
"idempotency_key": "stable_operation_key"
}

project_id may be null for a client-wide update. The transaction creates the published update, immutable publication receipt, audit row, and notification outbox event. Validation returns 400, authorization returns 403, and revision or idempotency conflicts return 409.

Publish and review immutable deliverable versions​

Admins and leads publish an approved release-ready deliverable:

POST /api/deliverable-release
Content-Type: application/json

{
"deliverable_id": "deliverable_uuid",
"reason": "Approved draft released from the workbench",
"idempotency_key": "stable_operation_key"
}

Only admins and leads can release. The deliverable must be approved and link to a source draft whose quality_gate_status is ready, qa_report.issues is empty, and missing_inputs is empty. The route rechecks those facts and returns { ok, deliverable, version, receipt }.

The database transaction creates deliverable_versions, sets deliverables.current_version_id, creates a ready_for_review state at revision 1 and the immutable release receipt, and enqueues release.published and review.requested notifications. The portal shows a deliverable only when visible_to_client is true and current_version_id identifies its current immutable version.

An exact idempotent replay returns the existing release. Releasing unchanged current content returns already_released: true and queues no duplicate notification. Invalid input returns 400, forbidden scope returns 403, missing records return 404, and stale, semantic, or readiness conflicts return 409.

A pending legacy deliverable_release approval can reach the same atomic release through POST /api/approvals with { "action": "approve", "id": "approval_uuid", "reason": "..." }. Its payload must resolve the exact deliverable ID. The RPC consumes the approval on success and uses deliverable-approval:<approval id> as the stable key; a readiness failure leaves the approval pending.

Submit a version-specific review:

POST /api/deliverable-reviews
Content-Type: application/json

{
"deliverable_id": "deliverable_uuid",
"version_id": "version_uuid",
"expected_revision": 1,
"idempotency_key": "stable_operation_key",
"action": "approve",
"comment": "Approved for use"
}

Actions are approve, request_changes, and comment. request_changes requires a comment. A viewer can comment; approve and request-changes actions require the explicit can_approve_deliverables capability. A decision against a superseded version or stale revision returns HTTP 409.

Create or update a shared milestone​

POST /api/portal-milestones accepts create or update. Create requires client_id, title, outcome, status, due_at, and idempotency_key. Update requires milestone_id, expected_revision, and idempotency_key.

Optional fields are project_id on create, owner_person_id, blocked_by_id, schedule_note, commitments, and portal_visible. A mutation replaces the complete commitments array.

Each commitment has this shape:

{
"id": "commitment_uuid",
"text": "Confirm the launch audience",
"status": "pending",
"owner_person_id": "person_uuid",
"due_at": "2026-09-15T17:00:00.000Z"
}

Commitment status is pending or completed. Reads include allowed owner and blocker choices, allowed milestone statuses, row-level can_manage, and revision. The database rejects invalid owners, cross-scope blockers, self-blocking, and dependency cycles.

Manage access​

Admins and leads use /api/portal-access:

RequestRequired fieldsResult
GET ?client_id=client_uuidclient_idMembers and invitation lifecycle state
POST invite_memberclient_id, email, role, explicit email choiceMembership, invitation, and delivery state
POST resend_inviteclient_id, invitation_idNew Auth invite or existing-user access notification
POST send_password_resetclient_id, person_idSupabase Auth recovery delivery state
POST revoke_memberclient_id, membership_id, reasonRevoked membership and invitation IDs

Invite roles are client_viewer and client_reviewer. Invitation and recovery redirects pass through /auth-callback.html, persist the session, require password setup when appropriate, reject open redirects, and return to the requested portal destination.

Invitation responses report delivery.state as delivered, pending, or failed. Revocation reports not_sent; it removes access without deleting a reusable Auth identity. Accepted and revoked invitations cannot be resent.

Read notifications and update preferences​

List the signed-in person’s scoped inbox:

GET /api/portal-notifications?client_id=client_uuid&limit=25&cursor=opaque_cursor

The response contains shaped notification rows, unread_count, and page. Authorization, list results, unread count, and mark-read operations use the same database scope.

Mark one or all visible rows read:

{ "action": "mark_read", "client_id": "client_uuid", "notification_id": "notification_uuid" }
{ "action": "mark_all_read", "client_id": "client_uuid" }

Read preferences with GET /api/portal-notifications?client_id=client_uuid&resource=preferences. Update them with PUT /api/portal-notifications:

{
"client_id": "client_uuid",
"expected_revision": 2,
"email_enabled": true,
"digest_frequency": "daily",
"categories": {
"requests": true,
"reviews": true,
"milestones": true,
"releases": true,
"access": true
},
"quiet_hours": {
"start": "20:00",
"end": "08:00",
"timezone": "America/New_York"
}
}

Digest frequency is immediate, daily, weekly, or off. Quiet hours require both times and a valid Internet Assigned Numbers Authority time zone. In-app delivery and the access category remain enabled. A stale preference revision returns HTTP 409 with the current preferences.

Ask a grounded workspace question​

POST /api/portal-assistant accepts client_id and a question between 3 and 2,000 characters. It loads every portal view with projection: "released", selects bounded evidence across content types, and calls Anthropic without mutable tools.

The response contains answer, supported, citations, and generated_at. Citation URLs come from the authorized evidence map, not from model output. A supported answer must contain at least one valid citation. No matching shared evidence returns supported: false and an empty citation array.

Operate the delivery worker​

Domain mutations write portal_notification_outbox in the same transaction as the visible change. The scheduled portal-notifications Edge Function claims outbox and delivery rows with compare-and-swap leases, revalidates recipients and preferences, and sends through Resend.

The worker uses these service-role contracts:

  • claim_portal_notification_outbox and complete_portal_notification_outbox
  • resolve_portal_notification_recipients and materialize_portal_notification
  • claim_portal_notification_deliveries, complete_portal_notification_delivery, and defer_portal_notification_delivery
  • assign_portal_notification_delivery_batch, complete_portal_notification_delivery_batch, and defer_portal_notification_delivery_batch
  • enqueue_portal_due_reminders
  • claim_stale_portal_attachments, complete_portal_attachment_cleanup, and fail_portal_attachment_cleanup

Persisted digest batches are indivisible and reuse one provider idempotency key across lease recovery. Attachment cleanup claims unpublished pending, ready, quarantined, or retryable cleanup rows, changes them to deleting, and requires the cleanup token to complete or defer the deletion.