Skip to content
Browse developers

API reference

Every endpoint, generated from the same contract the API serves, so it cannot fall behind.

Last updated

Generated from the CecurSign API contract on : 355 operations in 46 groups, from version 1.0 of the contract. For the machine-readable document, call GET /v1/openapi.json with a key that has read access.

Before you read this

Every path below already carries the /v1 prefix, so a request goes to https://cecursign.io/api plus the path. The full base URL is https://cecursign.io/api/v1.

Most endpoints accept an API key as a bearer token. Some are reachable from a signed-in session in the app, and a few need no credential at all: the signing ceremony and public proposal acceptance are opened by the link the recipient was sent. Each entry states which applies. For how to obtain and send a key, read Authentication and API keys.

X-Acting-User

Accepted on every endpoint, so it is stated once here rather than repeated on every one of the 355 below. On an API-key request, the person at the firm whose authority this call carries: a CecurSign user id, or the email address of a user in the same account. Verified against a live user before the request runs. An unknown or cross-account value is rejected with 400, never silently ignored. When present, the audit trail and the certificate of completion name that person alongside the integration; when absent, the act is recorded against the API key alone. Required on keys created with `requireActingUser`, and required by POST /v1/envelopes, which must record an author.

Groups

Analytics

GET/v1/activityTenant activity feed

Reverse-chronological, cursor-paginated, merged from the real signing audit trail and the proposal event log. Deliberately returns NO total: a count over a cursor-paginated union is either a second expensive query or a lie, and the page this replaces displayed a lie (a count over fifty envelopes, labelled "Total Events"). High-frequency per-field and per-email audit rows are filtered out of the feed but remain in the envelope audit trail.

Opens with: API key

Parameters

  • cursorstringqueryOpaque cursor from the previous page. Encodes the last row timestamp and id, so a feed that gains rows while being read does not skip or repeat.
  • limitnumberquery
  • subjectsigning | proposalsqueryRestrict to one subject. Omitted means both — and for a caller without proposals, both means signing, because the proposal half is never queried for them.

Responses

  • 200ActivityFeedResponseDto
GET/v1/analytics/exportExport analytics data

Exports analytics data as CSV or JSON for the specified period.

Opens with: API key

Parameters

  • startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.
  • endDatestringqueryEnd date (ISO 8601). Defaults to now.
  • formatcsv | jsonqueryExport format: csv or json. Defaults to csv.

Responses

  • 200Exported analytics data
  • 403Feature not available on current plan
GET/v1/analytics/funnelGet conversion funnel

Returns the conversion funnel: Sent -> Viewed -> Signed -> Completed with drop-off percentages.

Opens with: API key

Parameters

  • startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.
  • endDatestringqueryEnd date (ISO 8601). Defaults to now.

Responses

  • 200FunnelResponseDtoConversion funnel data
  • 403Feature not available on current plan
GET/v1/analytics/peak-timesGet peak signing times

Returns signing activity grouped by day-of-week and hour-of-day.

Opens with: API key

Parameters

  • startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.
  • endDatestringqueryEnd date (ISO 8601). Defaults to now.
  • tzOffsetMinutesnumberqueryThe caller's Date#getTimezoneOffset() in minutes (UTC+1 sends -60). Defaults to 0 (UTC).

Responses

  • 200PeakTimesResponseDtoPeak signing times data
  • 403Feature not available on current plan
GET/v1/analytics/proposals/by-authorPer-author proposal performance

Attributed by `createdById`. Proposals with no author — created over an API key or by the renewal sweep — group under one "API / automated" row rather than being dropped, so the rows still sum to the total.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)

Responses

  • 200ProposalAuthorsResponseDto
GET/v1/analytics/proposals/by-clientTop clients by won value

Counts cohort on sentAt so each client win rate means the same thing as the headline one; money folds over the accepted subset of that cohort. Ordered by an annualised composite that is computed but never returned — the pricing rule bans STATING a merged figure to a reader, and an ordering is not such a statement. The alternative, ranking by count, puts ten small clients above one large one.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)

Responses

  • 200ClientValueResponseDto
GET/v1/analytics/proposals/client/{contactId}Proposal history for one client

Counts are ALL-TIME and money is a rolling 12 months, both labelled as such on screen. "Have we ever won work from this client" is a relationship question with no natural cutoff; "what are they worth" decays. Rolling rather than "this year" because a financial year only exists for tenants that have configured one.

Opens with: API key

Parameters

  • contactIdstringpathRequired

Responses

  • 200ClientProposalSummaryDto
GET/v1/analytics/proposals/deal-sizeMedian deal size, per fee bucket

NOT a single "average deal size" — there is no single deal size, because a setup fee plus a monthly fee is two figures and adding them is the banned merge. Median rather than mean: at this volume one large engagement dominates a mean, and a median over lower-bound values is still a correct lower bound. Buckets with fewer than 3 samples are omitted entirely — a median of one is a number pretending to be a statistic.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)

Responses

  • 200ProposalMoneyResponseDto
GET/v1/analytics/proposals/lost-reasonsWhy proposals were lost

Includes a `reason: null` row for losses nobody annotated. That row is not a data gap to be hidden. It is the number that tells a company its own habit needs forming.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)

Responses

  • 200ProposalLostReasonsResponseDto
GET/v1/analytics/proposals/open-valueValue quoted and not yet answered on

What "pipeline value" means here, and the definition is narrow on purpose: proposals in SENT / VIEWED / SIGNING, as of NOW rather than over the window, excluding drafts (an unsent draft is not pipeline) and excluding anything manually marked lost. Deliberately NOT probability-weighted — a win-probability model fitted on forty proposals a year is astrology, and showing it beside real figures launders it.

Opens with: API key

Responses

  • 200ProposalMoneyResponseDto
GET/v1/analytics/proposals/outcomesWon / lost / open over time

Every segment belongs to the SAME cohort — proposals sent in that period — so the bars sum to the sends and cannot disagree with the summary above them.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)
  • granularityday | week | monthqueryGranularity for trend data

Responses

  • 200ProposalOutcomesResponseDto
GET/v1/analytics/proposals/staleOpen proposals with no expiry date, sent over 60 days ago

The population that can never be recorded as lost automatically, and therefore the group silently flattering the win rate. Not date-filtered — it is a work list about today.

Opens with: API key

Responses

  • 200StaleProposalsResponseDto
GET/v1/analytics/proposals/summaryProposal KPIs: sent, won, lost, open, win rate, time to accept

Win rate is won / (won + lost) over proposals SENT in the window, and is NULL — not 0 — when nothing has been decided. Always render it beside `open`: a rate without the undecided count beside it is unreadable for a young cohort. `openNow` and `staleCount` deliberately ignore the window; they are questions about today.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)

Responses

  • 200ProposalSummaryResponseDto
GET/v1/analytics/proposals/won-valueValue of work won, per currency and per fee bucket

Proposals ACCEPTED in the window — note the cohort differs from the count metrics, which use sentAt: "what did we win this quarter" is a question about when we won it. Recurring buckets carry an annualised RUN RATE alongside the per-period figure; per-period x 12 does not equal it and it is not a forecast. `unpricedCount` reports proposals excluded for having no pricing at all, so a reader knows what the total is actually over.

Opens with: API key

Parameters

  • startDatestringqueryStart date for the analytics period (ISO 8601)
  • endDatestringqueryEnd date for the analytics period (ISO 8601)

Responses

  • 200ProposalMoneyResponseDto
GET/v1/analytics/summaryGet analytics summary

Returns high-level KPIs including total sent, completion rate, average time-to-sign, and more.

Opens with: API key

Parameters

  • startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.
  • endDatestringqueryEnd date (ISO 8601). Defaults to now.

Responses

  • 200SummaryResponseDtoAnalytics summary
  • 403Feature not available on current plan
GET/v1/analytics/team-performanceGet team performance metrics

Returns per-user completion rates, volume, and average time-to-sign. Requires an OWNER or ADMIN session, matching GET /v1/team/members which returns the same roster.

Opens with: API key

Parameters

  • startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.
  • endDatestringqueryEnd date (ISO 8601). Defaults to now.

Responses

  • 200TeamPerformanceResponseDtoTeam performance data
  • 403Insufficient role, or feature not on current plan
GET/v1/analytics/time-to-signGet time-to-sign distribution

Returns time-to-sign distribution in buckets with percentiles (median, average, p90).

Opens with: API key

Parameters

  • startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.
  • endDatestringqueryEnd date (ISO 8601). Defaults to now.

Responses

  • 200TimeToSignResponseDtoTime-to-sign distribution
  • 403Feature not available on current plan

API Keys

GET/v1/auth/api-keysList all API keys

Requires an OWNER or ADMIN session.

Opens with: API key

Responses

  • 200ApiKeyListResponseDtoList of API keys
  • 401Not authenticated
  • 403An API key (a key cannot manage keys), or a role below ADMIN
POST/v1/auth/api-keysCreate a new API key

Requires an OWNER or ADMIN session. A key is tenant-wide and unrestricted by department, so minting one is an escalation for anyone who is not already company-wide.

Opens with: API key

Request body: CreateApiKeyDto

  • expiresAtstringExpiration date for the API key (optional)
  • namestringRequiredName for the API key (for identification)
  • permissionsREAD | WRITE[]Permissions for the API key
  • requireActingUserbooleanRefuse any write on this key that does not name the person it is acting for, via the `X-Acting-User` header (a CecurSign user id or the email address of a user in this account). Recommended for partner integrations that act on behalf of named staff: without it, a machine-driven send is recorded against the key alone, and the audit record is append-only, so the attribution cannot be added afterwards. Defaults to false, which keeps existing integrations working unchanged.

Responses

  • 201CreatedApiKeyResponseDtoAPI key created successfully
  • 400Invalid input
  • 401Not authenticated
  • 403An API key (a key cannot mint a key), a role below ADMIN, or API access not available on the current plan
GET/v1/auth/api-keys/{id}Get API key details

Requires an OWNER or ADMIN session.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200ApiKeyResponseDtoAPI key details
  • 401Not authenticated
  • 403An API key (a key cannot manage keys), or a role below ADMIN
  • 404API key not found
DELETE/v1/auth/api-keys/{id}Revoke an API key

Requires an OWNER or ADMIN session. Revoking is a denial of service against every integration the company runs off that key.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200MessageResponseDtoAPI key revoked
  • 401Not authenticated
  • 403An API key (a key cannot manage keys), or a role below ADMIN
  • 404API key not found

Authentication

POST/v1/auth/forgot-passwordRequest password reset

Opens with: No credential.

Request body: ForgotPasswordDto

  • emailstringRequiredUser email address

Responses

  • 200MessageResponseDtoReset email sent if account exists
  • 429Too many password reset requests
POST/v1/auth/loginLogin with email and password

Opens with: No credential.

Request body: LoginDto

  • emailstringRequiredUser email address
  • passwordstringRequiredUser password

Responses

  • 200LoginResponseDtoLogin successful
  • 401Invalid credentials
  • 429Too many login attempts
POST/v1/auth/logoutLogout current session

Opens with: Signed-in session

Responses

  • 200MessageResponseDtoLogged out successfully
GET/v1/auth/meGet current user session, or why there is none

Opens with: Signed-in session

Responses

  • 200Returns current user, or authenticated:false with a reason
POST/v1/auth/me/resend-verificationResend the signed-in user's email-verification link

Opens with: Signed-in session

Responses

  • 200Accepted
POST/v1/auth/registerSelf-registration (deprecated, sign up at cecur.io)

Opens with: No credential.

Responses

  • 410Self-registration is no longer available
POST/v1/auth/resend-verificationResend email verification

Opens with: No credential.

Request body: ResendVerificationDto

  • emailstringRequiredUser email address

Responses

  • 200MessageResponseDtoVerification email sent if applicable
  • 429Too many verification email requests
POST/v1/auth/reset-passwordReset password with token

Opens with: No credential.

Request body: ResetPasswordDto

  • passwordstringRequiredNew password (min 8 chars, must include uppercase, number, and special character)
  • tokenstringRequiredPassword reset token from email

Responses

  • 200MessageResponseDtoPassword reset successful
  • 400Invalid or expired token
  • 429Too many password reset attempts
POST/v1/auth/verify-emailVerify email with token

Opens with: No credential.

Request body: VerifyEmailDto

  • tokenstringRequiredEmail verification token

Responses

  • 200VerifyEmailResponseDtoEmail verified
  • 400Invalid or expired token

Billing

GET/v1/billing/addonsVolume packs: what this tenant holds, and what their tier can hold

A pack is capacity bought once and spent only after the plan's own monthly allowance runs out, in whatever month that happens. The figures are ADDITIONS to the plan, not replacement caps.

Opens with: API key

Responses

  • 200AddOnsResponseDtoBalance, offers and grants
POST/v1/billing/addons/checkoutStart Stripe Checkout for a booster pack, via Cecur Home

Opens with: API key

Request body: StartAddOnCheckoutDto

  • addOnCodestringRequiredBooster code from CecurSign's catalogue
  • quantitynumberHow many packs. Defaults to 1.

Responses

  • 200AddOnCheckoutSessionResponseDtoCheckout session created; redirect the browser to `url`
  • 403The booster is not sold on this account's plan
  • 503Cecur Home could not be reached
POST/v1/billing/checkoutStart Stripe Checkout for a plan change, via Cecur Home

Opens with: API key

Request body: StartCheckoutDto

  • billingCyclemonthly | annualRequiredBilling cadence
  • confirmedImmediateChargebooleanSet when the customer has been shown the amount quoted by POST /billing/preview and confirmed it.
  • planINDIVIDUAL | PROFESSIONAL | BUSINESS | ENTERPRISERequiredPlan to move to

Responses

  • 200CheckoutSessionResponseDtoCheckout session created; redirect the browser to `url`
  • 503Cecur Home could not be reached
GET/v1/billing/entitlementsWhat this tenant can actually use, and why they might want a different plan

Opens with: API key

Responses

  • 200EntitlementsResponseDtoEntitlements
GET/v1/billing/plansGet available plans with pricing (public)

Opens with: No credential.

Responses

  • 200PlansResponseDtoAvailable plans
POST/v1/billing/previewQuote what a plan change costs today, before making it

Opens with: API key

Request body: PreviewPlanChangeDto

  • billingCyclemonthly | annualRequiredBilling cadence being considered
  • planINDIVIDUAL | PROFESSIONAL | BUSINESS | ENTERPRISERequiredPlan being considered

Responses

  • 200PlanChangePreviewDtoWhat the change would do, and what it would cost including VAT
  • 503Cecur Home could not be reached
GET/v1/billing/subscriptionGet current plan and subscription details

Opens with: API key

Responses

  • 200SubscriptionDetailsDtoSubscription details
GET/v1/billing/usageGet current usage stats vs plan limits

Opens with: API key

Responses

  • 200UsageStatsDtoUsage statistics

Branding (Public)

Bulk Send

GET/v1/bulk-sendList bulk sends

Get all bulk send operations for your account

Opens with: API key

Responses

  • 200BulkSendListResponseDtoList of bulk sends
POST/v1/bulk-sendCreate a bulk send

Create and start a bulk send operation from a template

Opens with: API key

Request body: CreateBulkSendDto

  • csvRoleIdstringTemplate role ID that the uploaded recipients fill (the "variable" signer). Defaults to the first SIGNER role on the template when omitted.
  • namestringRequiredName for this bulk send batch
  • recipientsBulkSendRecipientInput[]RequiredList of recipients (max 500). Each one becomes its own envelope, mapped to the template role identified by csvRoleId.
  • roleAssignmentsBulkSendRoleAssignmentDto[]How the template's OTHER roles are filled on every envelope (fixed recipient or self-sign). Required for any template role that is not csvRoleId.
  • templateIdstringRequiredTemplate ID to use for envelopes

Responses

  • 201BulkSendResponseDtoBulk send created and queued
  • 400Invalid request
  • 404Template not found
GET/v1/bulk-send/{id}Get bulk send details

Get detailed status of a bulk send operation including recipients

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200BulkSendDetailResponseDtoBulk send details
  • 404Bulk send not found
POST/v1/bulk-send/{id}/cancelCancel a bulk send

Cancel a pending or in-progress bulk send operation

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200BulkSendResponseDtoBulk send cancelled
  • 400Cannot cancel
  • 404Bulk send not found

Cecur Book (Public)

GET/v1/book/public/bookings/{token}Read a booking by a cancel or reschedule token

Either token opens this read, because both emailed links lead to a page that must show the meeting before offering an action. What each token may DO is decided by the action routes below, not here.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe booking’s cancel or reschedule token

Responses

  • 200PublicBookingResponse
  • 404PublicNotFoundResponse
  • 429Too many requests
GET/v1/book/public/bookings/{token}/calendar.icsDownload one booking as an iCalendar document

The Apple arm of the confirmation email’s “Add to calendar” row, and the fallback for any client that opens text/calendar. Either booking token opens it. This is NOT the per-host subscription feed.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe booking’s cancel or reschedule token

Responses

  • 200An iCalendar VCALENDAR document
  • 404
  • 429Too many requests
POST/v1/book/public/bookings/{token}/cancelCancel a booking

Accepts the CANCEL token only; a reschedule link cannot call a meeting off. Frees the slot for rebooking immediately. Clicking twice succeeds both times and notifies the firm once.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe booking’s cancel token

Request body: PublicCancelDto

  • reasonstring

Responses

  • 200Cancelled
  • 404PublicNotFoundResponse
  • 409This booking is in a state that cannot be cancelled
  • 429Too many requests
POST/v1/book/public/bookings/{token}/rescheduleMove a booking to a new time

SINGLE USE. The link is spent the moment it succeeds, and a second attempt with the same token gets the same answer as an unknown one. The response carries the new booking’s own cancel and reschedule links.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe booking’s reschedule token

Request body: PublicRescheduleDto

  • reasonstring
  • startstringRequiredThe new slot start, ISO-8601.
  • timeZonestringThe booker’s time zone, if it has changed since they first booked.

Responses

  • 201PublicBookingCreatedResponse
  • 404PublicNotFoundResponse
  • 409That time is not on offer, or was taken while this request was in flight
  • 429Too many requests
GET/v1/book/public/bookings/{token}/reschedule/slotsList slots this booking could move to

The booking being moved is excluded from the host’s busy time, so a booker nudging a call by fifteen minutes is shown the times around where it currently sits rather than a hole where their own meeting is.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe booking’s reschedule token
  • fromstringqueryRequiredISO-8601. Start of the window to list slots in.
  • tostringqueryRequiredISO-8601. End of the window. At most 62 days after `from`.

Responses

  • 200PublicSlotsResponse
  • 404PublicNotFoundResponse
  • 429Too many requests
GET/v1/book/public/by-handle/{handle}/{slug}Resolve a pretty booking URL to its event type (no authentication)

Returns everything `GET event-types/{token}` returns, plus the token itself, which every later call in the booking flow is addressed by. A handle that does not exist and an event slug that does not exist give the same answer, deliberately: a handle is a person’s name and is guessable, so telling the two apart would disclose which names are held.

Opens with: No credential.

Parameters

  • handlestringpathRequiredThe host’s booking handle. Case-insensitive.
  • slugstringpathRequiredThe event type’s slug. Case-insensitive.

Responses

  • 200PublicHandleEventTypeResponse
  • 404PublicNotFoundResponse
  • 429Too many requests
GET/v1/book/public/event-types/{token}Read a bookable event type by its public token (no authentication)

Everything the booking page needs before a slot is chosen: the meeting, the host, the firm’s branding and the host’s time zone. Writes nothing.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe event type’s public token (96 hex characters)

Responses

  • 200PublicEventTypeResponse
  • 404PublicNotFoundResponse
  • 429Too many requests
POST/v1/book/public/event-types/{token}/bookingsBook a slot

Creates the booking and finds or creates the booker’s contact record with the firm, keyed on their email address, so a returning client’s meetings land on the record they already have rather than a duplicate. The response carries the cancel and reschedule links ONCE — they are not retrievable afterwards from any read route. Send an `Idempotency-Key` header to make a retry safe.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe event type’s public token

Request body: CreatePublicBookingDto

  • emailstringRequiredThe booker’s email. Lowercased and used to find or create their contact record with the firm, so a returning client’s meetings all land on the record they already have.
  • guestsPublicBookingGuestDto[]
  • namestringThe booker’s name. Used only if the firm has no name for them already; a public form never overwrites a record the firm has curated.
  • responsesobjectAnswers to the event type’s questions, keyed by question id.
  • startstringRequiredThe slot start, ISO-8601. It must be one of the instants `GET .../slots` currently offers: the server recomputes availability rather than trusting this value.
  • timeZonestringRequiredThe booker’s IANA time zone, from `Intl.DateTimeFormat().resolvedOptions().timeZone`. Stored so the confirmation can state a time they recognise. It never decides when the meeting is — that is the host’s zone, and the value here is presentation only.

Responses

  • 201PublicBookingCreatedResponse
  • 404PublicNotFoundResponse
  • 409That time is not on offer, or somebody else took it while this request was in flight
  • 429Too many requests
GET/v1/book/public/event-types/{token}/slotsList bookable slots

Returns UTC instants. Render them in whichever zone the booker has selected: the API deliberately does not format times, so changing the zone selector re-renders without a refetch. The host’s zone is what decided WHICH instants are offered; the booker’s zone decides nothing.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe event type’s public token
  • fromstringqueryRequiredISO-8601. Start of the window to list slots in.
  • tostringqueryRequiredISO-8601. End of the window. At most 62 days after `from`.

Responses

  • 200PublicSlotsResponse
  • 400Inverted window, or one longer than 62 days
  • 404PublicNotFoundResponse
  • 429Too many requests
GET/v1/book/public/hosts/{handle}Read a host’s public booking page (no authentication)

Everything `book.cecursign.io/{handle}` shows: who the host is, the firm’s branding, and every meeting kind they currently publish. Event types are returned by SLUG and never by token, because every link the page draws is the bare form. A host with nothing published returns an empty list rather than a 404.

Opens with: No credential.

Parameters

  • handlestringpathRequiredThe host’s booking handle. Case-insensitive.

Responses

  • 200PublicHostPageResponse
  • 404PublicNotFoundResponse
  • 429Too many requests

Cecur Book: booking handle

GET/v1/scheduling/handleRead my booking handle

Returns `handle: null` and `claimable: true` for a host who has not claimed one. Once claimed, `claimable` is false for ever: a handle cannot be changed, cleared or transferred, because it is part of booking links that have already been sent and cannot be recalled.

Opens with: API key

Responses

  • 200BookingHandleResponse
POST/v1/scheduling/handleClaim my booking handle

IRREVERSIBLE. The handle becomes the first segment of every pretty booking link this host sends, it is unique across every tenant, and there is no route that changes, clears or transfers it. The database refuses a second write independently of this API.

Opens with: API key

Request body: ClaimHandleDto

  • handlestringRequiredThe handle to claim, case-insensitive. Lower-cased before it is stored. THIS CANNOT BE CHANGED AFTERWARDS: it becomes part of every booking link this host sends, and those links cannot be recalled.

Responses

  • 201BookingHandleResponse
  • 400The handle is malformed or reserved. The request is wrong on its face and would be wrong against an empty database, so it is not a conflict.
  • 409Another host already holds this handle, or this host has already claimed one. Both are conflicts with the current state rather than with the request, and the message says which. A second claim is NEVER a silent success.
  • 429Too many requests
GET/v1/scheduling/handle/availabilityCheck whether a handle can be claimed

A courtesy for the claim form, not a reservation. Nothing is held between this call and the claim, and the estate-wide unique index is what actually decides who gets the name.

Opens with: API key

Parameters

  • handlestringqueryRequiredThe candidate handle. Case-insensitive.

Responses

  • 200HandleAvailabilityResponse
  • 429Too many requests

Cecur Book: bookings

GET/v1/scheduling/bookingsList bookings

Owners and admins see the whole firm and may filter to one colleague. Everyone else sees only their own meetings, whatever `userId` they pass.

Opens with: API key

Parameters

  • fromstringqueryISO-8601. Bookings ending after this instant.
  • tostringqueryISO-8601. Bookings starting before this instant.
  • statusPENDING_PAYMENT | CONFIRMED | CANCELLED | RESCHEDULED | COMPLETED | NO_SHOWquery
  • userIdstringqueryFilter to one host. Admins and owners may name any colleague; everyone else sees only their own bookings whatever they pass.
  • contactIdstringqueryFilter to the meetings held with one client. Added for the client record’s Meetings tab, where the alternative is fetching the whole diary and discarding most of it in the browser. It narrows within the caller’s existing visibility rather than widening it: a MEMBER still sees only their own meetings with that client.
  • orderasc | descqueryOrdering by start time. `asc` reads a diary forwards, which is right for what is coming up; `desc` reads history backwards, which is what a Past tab needs — with `asc` its first page is the oldest meeting the firm ever held.
  • limitnumberquery
  • offsetnumberquery

Responses

  • 200BookingListResponse
GET/v1/scheduling/bookings/{id}Get one booking

Opens with: API key

Parameters

  • iduuidpathRequired

Responses

  • 200BookingResponse
  • 404Booking not found
POST/v1/scheduling/bookings/{id}/cancelCancel a booking

Frees the slot immediately: a cancelled booking falls out of the database constraint that reserves it, so the time becomes bookable again with nothing else to do. Cancelling an already-cancelled booking succeeds and fires no second webhook.

Opens with: API key

Parameters

  • iduuidpathRequired

Request body: CancelBookingDto

  • reasonstring

Responses

  • 200BookingActionResponse
  • 404No such booking, or not one this caller may cancel
  • 409This booking is in a state that cannot be cancelled
POST/v1/scheduling/bookings/{id}/rescheduleMove a booking to a new time

Creates a NEW booking linked back to this one and moves this one to RESCHEDULED. The response carries the NEW booking’s id; a caller keying its own record on booking id must move that record rather than create a second one.

Opens with: API key

Parameters

  • iduuidpathRequired

Request body: RescheduleBookingDto

  • reasonstring
  • startstringRequiredThe new start, ISO-8601 UTC. It must be one of the instants the availability endpoint currently offers; the server recomputes rather than trusting the caller.

Responses

  • 201BookingActionResponse
  • 404No such booking, or not one this caller may reschedule
  • 409The new time is not on offer, or was taken while this request was in flight, or the booking has already been cancelled or moved

Cecur Book: calendar feed

GET/v1/scheduling/calendar-feedRead my calendar subscription address

Returns `enabled: false` and three nulls when no feed has been switched on. The address is returned in full on every read, deliberately: it is something a host copies onto several devices over time, not a secret revealed once.

Opens with: API key

Responses

  • 200CalendarFeedResponse
POST/v1/scheduling/calendar-feedSwitch the feed on, or issue a new address

Any address previously issued to this host stops working immediately, and every device subscribed to it must be re-subscribed. That is the point: it is the remedy for a feed URL that has been shared or lost with a device.

Opens with: API key

Responses

  • 200CalendarFeedResponse
DELETE/v1/scheduling/calendar-feedSwitch the feed off

The address stops resolving at once and every subscribed device begins failing to refresh. Meetings already downloaded to a device are the device’s copy and are not reachable from here. Revoking twice succeeds both times.

Opens with: API key

Responses

  • 200CalendarFeedRevokedResponse

Cecur Book: calendars

GET/v1/scheduling/calendarsMy calendar connection

Whether this host has connected Google, which calendars block their availability, and whether the connection has stopped working. `available: false` means the SERVER is not configured for Google, which is different from the plan not including scheduling.

Opens with: API key

Responses

  • 200CalendarConnectionResponse
DELETE/v1/scheduling/calendars/googleDisconnect Google Calendar

Deletes the stored credential. Existing bookings are untouched and their calendar events are left where they are. This does NOT revoke our access at Google: only the Google account settings can do that, and the screen says where.

Opens with: API key

Responses

  • 200DisconnectCalendarResponse
GET/v1/scheduling/calendars/google/availableCalendars on my connected Google account

A live read from Google, so it reflects calendars added or unshared since connecting. Answers 404 when there is no connection at all, and 409 when there is one that has stopped working, because those need different things from the caller: connect, versus reconnect.

Opens with: API key

Responses

  • 200GoogleCalendarListResponse
  • 404No Google Calendar connection on this account.
  • 409The connection has stopped working and must be reconnected.
GET/v1/scheduling/calendars/google/connectBegin connecting Google Calendar

Returns the URL to send the browser to. The URL carries a single-use `state` token bound to the calling user and valid for ten minutes. Refuses BEFORE the consent screen if the server could not store the resulting tokens, so a host never grants access to an application that then has nothing to show for it.

Opens with: API key

Responses

  • 200StartGoogleConnectResponse
  • 503Google or the encryption keyring is not configured on this deployment.
PUT/v1/scheduling/calendars/google/selectionChoose which calendars block my availability, and which receives bookings

A PUT because it replaces the whole selection: the screen shows every calendar with a checkbox and sends back what is ticked, so a PATCH of individual ids would need a protocol for removal that the screen has no way to express.

Opens with: API key

Request body: UpdateCalendarSelectionDto

  • destinationCalendarIdobjectCalendar to write new bookings to, or null for none. It need NOT be one of the selected calendars: writing bookings to a dedicated calendar while blocking availability from a personal one is an ordinary arrangement.
  • selectedCalendarIdsstring[]RequiredCalendar ids to consult for busy time. An empty array is ACCEPTED and means Google is connected while nothing blocks anything, which is legal and is what a host who only wants bookings WRITTEN to Google would choose. It is deliberately not refused, because refusing it would remove that configuration. It IS surfaced: a connected account with no selection is functionally tier 0, so the settings screen carries a prominent warning for as long as it lasts and the double-booking caveat stays on the page.

Responses

  • 200CalendarConnectionResponse
  • 404No Google Calendar connection on this account, so there is nothing to configure.

Cecur Book: diary access

GET/v1/scheduling/colleaguesEverybody else in the firm, for choosing who to share your diary with

Deliberately wider than GET /scheduling/hosts: you share your diary with people you cannot currently see. Names and email addresses only, which every user already sees on contacts and envelopes. Not GET /team/members, which carries roles and invitation state and is admin-only for that reason.

Opens with: API key

Responses

  • 200ColleagueListResponse
GET/v1/scheduling/grantsEvery diary that has been shared, across the firm

Grouped by host. Owners and admins are not listed: they have access by role, over every diary, and there is nothing to revoke.

Opens with: API key

Responses

  • 200SchedulingGrantListResponse
POST/v1/scheduling/grantsOpen a diary to somebody

A host may open their own diary. An owner or an admin may open anybody's, which is how a colleague covers for somebody who is out without that person having arranged it. VIEW sees the meetings; MANAGE also cancels, moves and edits the hours behind them. Idempotent, and changing the level of an existing grant is an update rather than a revoke and re-grant.

Opens with: API key

Request body: GrantDiaryAccessDto

  • granteeUserIdstringRequiredWho it is being opened to.
  • hostUserIdstringRequiredWhose diary is being opened. A host may only name themselves; an owner or admin may name anybody.
  • levelVIEW | MANAGEDefaults to VIEW. A missing level must never mean the wider of the two: the default only applies when the caller did not say, and handing out control to whoever forgot the field is how an access table leaks.

Responses

  • 204Opened.
  • 400A host already has full access to their own diary.
  • 403You can only share your own diary.
  • 404One of the two people is not in this firm.
DELETE/v1/scheduling/grants/{hostUserId}/{granteeUserId}Close a diary again

Succeeds whether or not the grant was there. The intent is "this person should not have access", and that is satisfied either way.

Opens with: API key

Parameters

  • hostUserIdstringpathRequired
  • granteeUserIdstringpathRequired

Responses

  • 204Closed.
  • 403You can only share your own diary.
GET/v1/scheduling/hostsHosts whose diary you may see

Every host this caller may read, each with the level they hold over it: VIEW, or MANAGE if they may also cancel and reschedule. Always includes the caller, who has full access to their own diary unconditionally. Under the closed default that is often the whole list. This is the source for the Bookings host filter.

Opens with: API key

Responses

  • 200VisibleHostListResponse
GET/v1/scheduling/my-diary/accessWho can see and control your diary

The firm's owners and admins, who have access by role, plus everybody you or an admin has opened it to. Each grant says who opened it, so a host can tell their own sharing apart from an admin sharing on their behalf while they were away.

Opens with: API key

Responses

  • 200DiaryAccessResponse

Cecur Book: event types

GET/v1/scheduling/event-typesList event types

A session caller sees their own event types. An API key, which has no user of its own, sees the whole firm’s. An owner or admin may name a colleague with `userId` to see theirs instead, which is what the calendar’s team-member filter needs to draw a colleague’s bookable time; anyone else passing it still sees only their own.

Opens with: API key

Parameters

  • userIduuidqueryShow one colleague’s event types instead of your own. Owners and admins only; anybody else passing it still sees their own.

Responses

  • 200EventTypeListResponse
POST/v1/scheduling/event-typesCreate an event type

Mints the public booking link at the same time. `userId` is required for an API key, which has no user of its own to attribute the event type to.

Opens with: API key

Request body: CreateEventTypeDto

  • bufferAfterMinutesnumber
  • bufferBeforeMinutesnumberPadding applied before every busy block when working out availability. It applies to external calendar events exactly as it does to bookings; the engine cannot tell them apart and must not be able to.
  • descriptionstring
  • durationMinutesnumberRequired
  • isActiveboolean
  • minimumNoticeMinutesnumberHow far ahead of now the earliest bookable slot must sit, in minutes, so "two hours" and "one day" are the same field. Measured from the moment the page is rendered.
  • questionsobject[]Booker-facing questions, as authored. The array ORDER is the render order. Stored as JSON because nothing queries inside it.
  • remindGuestsbooleanWhether the people the booker invites are reminded the day before, alongside the booker and the host. Defaults to true. It governs the REMINDER ONLY: a guest receives the confirmation, the cancellation and the reschedule whatever this says, because not telling an invited attendee that a meeting they are on has moved is a defect rather than a preference. A guest is never sent a manage, reschedule, cancel or calendar link on any lane, since each of those is a credential the booker alone was granted.
  • rollingWindowDaysnumber
  • scheduleIdobjectThe schedule deciding when this event type can be booked. Omit to use the host’s default schedule, resolved when slots are rendered.
  • slotIntervalMinutesobjectStart-time granularity: 15 offers :00 :15 :30 :45. Null means "every durationMinutes", which is the common case. A value finer than the duration deliberately offers overlapping start times, as every scheduler in this market does.
  • slugstringRequiredThe last segment of the public link. Lowercase letters, digits and hyphens. Unique per host, not per firm, so two colleagues may each own an "intro-call".
  • titlestringRequired
  • userIdstringThe host. Required when the caller is an API key, which has no user of its own; defaults to the session user otherwise.

Responses

  • 201EventTypeResponse
  • 403Only an owner or admin may name another host
  • 409This host already has an event type with that slug, or their default schedule could not be written twice running (see POST /scheduling/schedules/default).
GET/v1/scheduling/event-types/{id}Get one event type

Owners and admins may read any host’s; everybody else gets a 404 for a colleague’s, the same answer another firm’s id gets. This matches `:id/slots` on the same row.

Opens with: API key

Parameters

  • iduuidpathRequired

Responses

  • 200EventTypeResponse
  • 404No such event type, or not one this caller may read
PATCH/v1/scheduling/event-types/{id}Update an event type

Changing the duration or the buffers changes what is offered from the next page render onwards. Bookings already made keep the length they were made at.

Opens with: API key

Parameters

  • iduuidpathRequired

Request body: UpdateEventTypeDto

  • bufferAfterMinutesnumber
  • bufferBeforeMinutesnumber
  • descriptionobject
  • durationMinutesnumber
  • isActiveboolean
  • minimumNoticeMinutesnumber
  • questionsobject[]
  • remindGuestsbooleanWhether invited guests are reminded the day before. The reminder only; guests always receive the confirmation, the cancellation and the reschedule.
  • rollingWindowDaysnumber
  • scheduleIdobject
  • slotIntervalMinutesobject
  • slugstring
  • titlestring

Responses

  • 200EventTypeResponse
  • 404No such event type, or not one this caller may edit
DELETE/v1/scheduling/event-types/{id}Delete an event type

Refused while any booking references it. Deactivate instead: that hides it from the public page and leaves the meetings in the diary intact.

Opens with: API key

Parameters

  • iduuidpathRequired

Responses

  • 204Deleted
  • 404No such event type, or not one this caller may delete
  • 409Bookings exist against this event type
GET/v1/scheduling/event-types/{id}/slotsList the times this event type currently has on offer

UTC instants, computed exactly as the public booking page computes them, so a start taken from here is one `POST /scheduling/bookings/:id/reschedule` will accept. Owners and admins may ask about any host’s event type; everybody else gets a 404 for a colleague’s, the same answer another firm’s id gets.

Opens with: API key

Parameters

  • iduuidpathRequired
  • fromstringqueryRequiredISO-8601. The start of the window to ask about. At most 62 days may be requested at once, which is the cap `SlotAvailabilityService` enforces for every caller.
  • tostringqueryRequiredISO-8601. The end of the window.
  • excludeBookingIduuidqueryThe booking being MOVED, whose own time must not block its replacement. Required to offer a host the slot their meeting already occupies. It is verified to belong to this firm and to the same host before anything is excluded, so it cannot be used to hide somebody else’s meeting from the availability calculation.

Responses

  • 200HostSlotListResponse
  • 400The window is inverted, longer than 62 days, or the event type resolves to no schedule
  • 404Event type not found

Cecur Book: schedules

GET/v1/scheduling/schedulesList schedules

A session caller sees their own schedules, whatever their role: this is the authoring list for the person looking at it. An API key, which has no user of its own, sees the whole firm’s. An owner or admin reads a colleague’s by id.

Opens with: API key

Responses

  • 200ScheduleListResponse
POST/v1/scheduling/schedulesCreate a schedule

The host’s first schedule becomes their default automatically, because an event type that names no schedule resolves through the default and would otherwise be unbookable with no visible cause.

Opens with: API key

Request body: CreateScheduleDto

  • isDefaultbooleanUsed by any of this host’s event types that name no schedule of their own.
  • namestringRequired
  • rulesAvailabilityRuleDto[]
  • timeZonestringRequiredThe IANA zone the weekly rules are AUTHORED in, and the single most important field here. "09:00 Monday" is not an instant until you know whose 09:00 it is. The booker’s zone never enters availability at all.
  • userIdstringThe host. Required for an API key, which has no user of its own.

Responses

  • 201ScheduleResponse
  • 400Unknown time zone, or a rule with no valid reading
  • 403Only an owner or admin may name another host
GET/v1/scheduling/schedules/{id}Get one schedule with its rules

Owners and admins may read any host’s schedule; everybody else gets a 404 for a colleague’s, the same answer another firm’s id gets.

Opens with: API key

Parameters

  • iduuidpathRequired

Responses

  • 200ScheduleResponse
  • 404No such schedule, or not one this caller may read
PATCH/v1/scheduling/schedules/{id}Rename a schedule, change its time zone, or make it the default

Changing the time zone MOVES the existing rules rather than reinterpreting them: 09:00 stays 09:00 and becomes a different instant. Any UI doing this owes the host a visible statement of what moved.

Opens with: API key

Parameters

  • iduuidpathRequired

Request body: UpdateScheduleDto

  • isDefaultboolean
  • namestring
  • timeZonestringChanging this MOVES the existing rules rather than reinterpreting them: the stored minutes-since-midnight values are unchanged, so 09:00 Dublin becomes 09:00 in the new zone, which is a different instant. The UI owes the host a visible statement of that.

Responses

  • 200ScheduleResponse
  • 404No such schedule, or not one this caller may edit
DELETE/v1/scheduling/schedules/{id}Delete a schedule

Refused while any event type points at it, which would otherwise make those event types silently unbookable.

Opens with: API key

Parameters

  • iduuidpathRequired

Responses

  • 204Deleted
  • 404No such schedule, or not one this caller may delete
  • 409Event types still use this schedule
PUT/v1/scheduling/schedules/{id}/rulesReplace every availability rule on a schedule

A PUT and not a PATCH: the body is the COMPLETE rule set and anything absent from it is deleted. A weekly grid is authored as one document, and a half-applied save could leave a host bookable on a day they had just closed.

Opens with: API key

Parameters

  • iduuidpathRequired

Request body: ReplaceAvailabilityDto

  • rulesAvailabilityRuleDto[]RequiredThe COMPLETE rule set. Anything not listed here is deleted.

Responses

  • 200ScheduleResponse
  • 400A rule with no valid reading, or two overrides on one date
  • 404No such schedule, or not one this caller may edit
POST/v1/scheduling/schedules/defaultGive me a default schedule, if I do not already have one

Returns the calling user’s default schedule, creating one with ordinary working hours (Monday to Friday, 09:00 to 17:00, in the firm’s region) if they have none. Idempotent, so it answers 200 rather than 201 and is safe to retry: a host who already has a default gets it back unchanged, with their own hours and their own time zone. Always the CALLER’S own, so there is no host to name, and an API key has no user and is refused.

Opens with: API key

Responses

  • 200ScheduleResponse
  • 400Authenticated with an API key, which has no user
  • 409The default schedule could not be written twice running, which means it is not a passing collision. Retrying will not clear it.

Contact

POST/v1/public/contactSend an enquiry from the public contact form

Emails the enquiry to the address that answers them and stores nothing. Public and unauthenticated: rate limited to 3 per 10 minutes per caller, with a honeypot field and a time-on-form check. Sends no acknowledgement to the enquirer, deliberately, so the endpoint cannot be used to mail an arbitrary address.

Opens with: No credential.

Request body: CreateContactEnquiryDto

  • companystringThe company they are enquiring for
  • elapsedMsnumberClient-measured time on the form, in milliseconds
  • emailstringRequiredWhere a reply should go
  • enquiryTypeGENERAL | SALES | SECURITY | EXISTING_CUSTOMERWhat the enquiry is about. Steers the subject line; grants nothing.
  • messagestringRequiredThe enquiry itself
  • namestringRequiredThe name the enquirer gave
  • websitestringAnti-spam honeypot. Must be empty. A filled value is accepted and discarded.

Responses

  • 202ContactEnquiryResponseDtoAccepted for delivery. Also returned for a submission the anti-abuse checks discarded, so an automated caller learns nothing about which check it failed.
  • 400Validation failure
  • 429Rate limited: try again shortly
  • 503The notification system is not configured or was unreachable. The message was NOT sent and the caller is told so.

Contacts

GET/v1/contactsList contacts

Get a paginated list of contacts with optional filtering

Opens with: API key

Parameters

  • pagenumberquery
  • pageSizenumberquery
  • searchstringquery
  • categorystringquery
  • favoritesOnlybooleanquery
  • includeArchivedbooleanqueryInclude archived contacts (excluded by default)
  • engagementLIVE | ENDING_SOON | LAPSED | WITH_CLIENT | UNATTENDEDqueryNarrow to one derived engagement bucket. Each bucket carries its own ordering — ENDING_SOON soonest-first, LAPSED/UNATTENDED most-recently-lapsed first, WITH_CLIENT longest-held first — because that is the only ordering that makes each one actionable and it is the one key every row in the bucket is guaranteed to have.

Responses

  • 200Returns paginated contacts list
POST/v1/contactsCreate contact

Create a new contact

Opens with: API key

Request body: CreateContactDto

  • addressstringPostal address (resolves the {{clientAddress}} proposal merge token)
  • categoriesstring[]Category tags for the contact
  • companystringCompany name
  • emailstringRequiredContact email address
  • isFavoritebooleanMark as favorite
  • namestringContact name
  • notesstringNotes about the contact
  • phonestringContact phone number

Responses

  • 201Contact created
  • 409Contact with this email already exists
GET/v1/contacts/{id}Get contact details

Get a single contact with detailed stats

Opens with: API key

Parameters

  • idstringpathRequiredContact ID

Responses

  • 200Returns contact details
  • 404Contact not found
PATCH/v1/contacts/{id}Update contact

Update an existing contact

Opens with: API key

Parameters

  • idstringpathRequiredContact ID

Request body: UpdateContactDto

  • addressstringPostal address (resolves the {{clientAddress}} proposal merge token)
  • categoriesstring[]Category tags for the contact
  • companystringCompany name
  • emailstringContact email address
  • isFavoritebooleanMark as favorite
  • namestringContact name
  • notesstringNotes about the contact
  • phonestringContact phone number

Responses

  • 200Contact updated
  • 404Contact not found
  • 409Contact with this email already exists
DELETE/v1/contacts/{id}Delete or archive a contact

Deletes the contact outright when it has no proposal or envelope history. When it does, the contact is ARCHIVED instead — hidden from the default list and restorable — because Proposal.contactId is a required Restrict foreign key. Check `outcome` on the response to tell which happened.

Opens with: API key

Parameters

  • idstringpathRequiredContact ID

Responses

  • 200DeleteContactResponse
  • 404Contact not found
GET/v1/contacts/{id}/documentsGet every document this client has been sent

Derived from the envelopes the contact is a recipient of and the proposals whose engagement letter was rendered for them — there is no manual document-to-client link to go stale. This is where rendered engagement letters live now that they are excluded from the company-wide document library.

Opens with: API key

Parameters

  • idstringpathRequiredContact ID
  • pagenumberquery
  • pageSizenumberquery

Responses

  • 200Returns the client’s documents, newest first
  • 404Contact not found
GET/v1/contacts/{id}/envelopesGet contact envelope history

Get all envelopes associated with a contact

Opens with: API key

Parameters

  • idstringpathRequiredContact ID
  • pagenumberquery
  • pageSizenumberquery

Responses

  • 200Returns envelope history
  • 404Contact not found
POST/v1/contacts/{id}/favoriteToggle favorite

Toggle the favorite status of a contact

Opens with: API key

Parameters

  • idstringpathRequiredContact ID

Responses

  • 200Favorite status toggled
  • 404Contact not found
POST/v1/contacts/{id}/restoreRestore an archived contact

Clears the archived flag, returning the contact to the default list. No-op if it is not archived.

Opens with: API key

Parameters

  • idstringpathRequiredContact ID

Responses

  • 201ContactResponse
  • 404Contact not found
GET/v1/contacts/categoriesGet all categories

Get all unique categories used across contacts

Opens with: API key

Responses

  • 200Returns list of category names
GET/v1/contacts/companiesGet all companies

Get every distinct company name in use, de-duplicated case-insensitively. Backs the company autocomplete on the contact form.

Opens with: API key

Responses

  • 200Returns list of company names
GET/v1/contacts/favoritesGet favorite contacts

Get all contacts marked as favorites

Opens with: API key

Responses

  • 200Returns favorite contacts
POST/v1/contacts/importImport contacts from a parsed CSV

Accepts rows already parsed by the client (same contract shape as bulk-send). Send `dryRun: true` for the preview step — it reports per-row validation and whether each row creates or updates, without writing. Committing upserts by (tenant, email) rather than failing on duplicates.

Opens with: API key

Request body: ImportContactsDto

  • dryRunbooleanWhen true, validate and report what WOULD happen without writing anything. Powers the preview step.
  • rowsImportContactRowDto[]RequiredParsed CSV rows, in file order

Responses

  • 201ImportContactsResponse
GET/v1/contacts/out-of-contractClients out of contract with nothing in flight

Every accepted engagement has ended and no proposal is open. The renewal nudge already exists as an overnight email from the proposal sweeps; this is the same fact, in the app, on the day somebody can act on it. Most recently lapsed first. Archived clients are excluded, because archiving is how a company says the relationship is over.

Opens with: API key

Parameters

  • limitnumberqueryMax rows (default 5, cap 50)

Responses

  • 200OutOfContractResponse
GET/v1/contacts/recentGet recent recipients

Get recently used recipient emails from envelope history

Opens with: API key

Parameters

  • limitnumberqueryMax results (default 10)

Responses

  • 200Returns recent recipients
GET/v1/contacts/statsEngagement counts for the Clients list tile row

Live / Ending soon / Lapsed / With the client, derived from proposals and never stored. Honours search, category, favourites and archived so the tiles caption the same population the list shows; ignores the engagement filter itself.

Opens with: API key

Parameters

  • searchstringquery
  • categorystringquery
  • favoritesOnlybooleanquery
  • includeArchivedbooleanquery

Responses

  • 200ContactStatsResponse

Dashboard

GET/v1/dashboard/layoutGet the current user's Home layout

Returns the widget list in render order. A user who has never customised Home gets a default generated from their tenant's current entitlements, with `customised: false` and nothing written to the database.

Opens with: API key

Responses

  • 200DashboardLayoutResponseDto
PUT/v1/dashboard/layoutReplace the current user's Home layout

Takes the complete widget list in render order and replaces what was stored. Creates the row on first save. Unknown widget ids are rejected by validation; duplicates are dropped rather than rejected, so a client bug cannot cost the user the rest of an arrangement they just built.

Opens with: API key

Request body: UpdateDashboardLayoutDto

  • showQuickStartbooleanWhether the quick start row above the grid is shown. Optional so a client built before the flag existed keeps saving layouts; absent means shown.
  • widgetsHomeWidgetPlacementDto[]RequiredThe complete widget list, IN RENDER ORDER. A full replacement, not a patch: the client holds the whole arrangement while the user drags it around, and a partial update would need an ordering protocol to express "this moved above that". An EMPTY array is legal — a user is allowed to clear their Home, and the picker is how they put it back.

Responses

  • 200DashboardLayoutResponseDto
  • 400Unknown widget id, or too many widgets
DELETE/v1/dashboard/layoutReset the current user's Home layout to the product default

Deletes the saved arrangement and returns the freshly generated default. The user goes back to tracking entitlement changes automatically, exactly as before they customised.

Opens with: API key

Responses

  • 200DashboardLayoutResponseDto
GET/v1/dashboard/summaryHome dashboard summary

Counts and the attention list that Home renders above its recent lists. Deliberately NOT gated on the analyticsAccess plan feature: these are operational counts of the tenant's own work, not reporting, and Home is the first screen every user sees on every plan. The proposals block is omitted entirely — not zero-filled — for callers who cannot use proposals.

Opens with: API key

Parameters

  • tzOffsetMinutesnumberqueryThe caller's Date#getTimezoneOffset() in minutes (UTC+1 sends -60). Used only to decide which local day each timestamp falls in for the weekly chart. Defaults to 0 (UTC).

Responses

  • 200DashboardSummaryResponseDto

Departments

GET/v1/departmentsList the company’s departments

Active only by default. Open to any authenticated user: the web labels with these.

Opens with: API key

Parameters

  • includeInactivestringqueryRequired

Responses

  • 200DepartmentListResponse
POST/v1/departmentsCreate a department

Opens with: API key

Request body: CreateDepartmentDto

  • descriptionstring
  • namestringRequired

Responses

  • 201DepartmentResponse
  • 409A department with that name already exists
PATCH/v1/departments/{id}Rename, re-describe, or retire a department

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateDepartmentDto

  • descriptionstring
  • isActivebooleanSet false to retire a department without deleting its history.
  • namestring

Responses

  • 200DepartmentResponse
  • 400The default department cannot be deactivated
  • 404Department not found
DELETE/v1/departments/{id}Retire a department

Soft — sets isActive=false and keeps membership, so reactivating restores the previous state. A department that has issued work cannot be hard-deleted without deciding what happens to that work, which is not a decision a delete button should take.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200DepartmentResponse
  • 400The default department cannot be deactivated
  • 404Department not found
GET/v1/departments/assignableDepartments the caller may file new work into

What the create-time picker offers. Narrower than GET /departments, which is every department in the company for labelling: this answers where THIS caller may put a new envelope or proposal, and is resolved by the same function the create endpoints validate against, so the picker cannot offer a choice the API would reject. When singleChoice is true the picker should not be rendered — there is no decision to make.

Opens with: API key

Responses

  • 200AssignableDepartmentsResponse
GET/v1/departments/meThe caller’s own visibility scope and departments

Returns the EFFECTIVE scope, not the stored column: OWNER and ADMIN always read TENANT. An API-key caller has no user and is tenant-wide, so it reports TENANT with no departments.

Opens with: API key

Responses

  • 200MyScopeResponse
GET/v1/departments/rosterEvery user in the company with their scope and departments

Deleted users are excluded; suspended users are not.

Opens with: API key

Responses

  • 200DepartmentRosterResponse
PUT/v1/departments/roster/{userId}Set one user’s visibility scope and department membership

Membership is replaced wholesale by departmentIds, so the stored state always matches what the admin had on screen. DEPARTMENT scope with no departments is rejected — it would produce a user who sees nothing, with no explanation.

Opens with: API key

Parameters

  • userIdstringpathRequiredThe user whose scope is being set

Request body: SetUserScopeDto

  • departmentIdsstring[]Replaces this user’s membership wholesale. Required and non-empty when visibilityScope is DEPARTMENT; ignored otherwise but still applied, so switching a user between DEPARTMENT and TENANT does not lose where they sit.
  • visibilityScopeOWN | DEPARTMENT | TENANTRequiredTENANT = the whole company (today’s behaviour for everyone). DEPARTMENT = only the departments listed below. OWN = only what this user created.

Responses

  • 200DepartmentRosterMemberResponse
  • 400Empty department list, or a department not in this company
  • 404No such user in this tenant

Document Categories

GET/v1/document-categoriesList document categories

The company-wide shelves for the document library. `documentCount` is scoped to what the caller can see and is intended for the management drawer on the Documents screen. Filter chips carry no count.

Opens with: API key

Responses

  • 200DocumentCategoryResponseDto[]
POST/v1/document-categoriesCreate a document category

Opens with: API key

Request body: CreateDocumentCategoryDto

  • colorstringHex colour for the chip. Omit for the neutral tone.
  • namestringRequiredCategory name, unique within the company
  • sortOrdernumberDisplay order, ascending

Responses

  • 201DocumentCategoryResponseDto
  • 409A category with this name already exists
PATCH/v1/document-categories/{id}Rename, recolour or reorder a category

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateDocumentCategoryDto

  • colorstringNew hex colour
  • namestringNew name
  • sortOrdernumberNew display order

Responses

  • 200DocumentCategoryResponseDto
  • 404Category not found
  • 409A category with this name already exists
DELETE/v1/document-categories/{id}Delete a category

Documents in it are NOT deleted — they return to "Uncategorised". No in-use guard, because nothing is lost by deleting a shelf.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 204Deleted
  • 404Category not found

Document copies

GET/v1/copies/{token}What a copy link points at, and record that it was opened

Returns only what the reader needs in order to decide whether to open the document: the subject, who sent it, the firm, their note and when the link expires. No envelope id, no recipient list and no signer names: the reader was not a party to the document. The FIRST open of a link writes one audit event; later opens move a counter on the row.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe token from the emailed link

Responses

  • 200PublicCopyResponseDto
  • 404The link is not valid
  • 410The link was withdrawn, has expired, or the document is no longer available
GET/v1/copies/{token}/documentDownload the signed document behind a copy link

Served through our API and re-verified against the hash recorded for the document, like every other route that hands over a signed PDF. A mismatch is refused rather than served, because nobody can tell from here whether the file or the record is wrong.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe token from the emailed link

Responses

  • 200The signed PDF
  • 404The link is not valid
  • 410The link was withdrawn or has expired

Document packs

GET/v1/document-packsList the firm's document packs

A pack is a named set of supporting items a template can carry, so a firm sending its onboarding bundle picks one thing instead of five. Firm-wide, like the templates that point at them.

Opens with: API key

Responses

  • 200DocumentPackListResponseDto
POST/v1/document-packsCreate a document pack

Items follow exactly the same rules as a supporting document: a library document or an https link to the firm's own site, never both. A library document is verified against the caller's tenant and department scope.

Opens with: API key

Request body: CreateDocumentPackDto

  • descriptionstringA note to the colleague choosing between packs. Never shown to a recipient.
  • itemsDocumentPackItemInputDto[]The items, in the order the recipient will read them. May be empty.
  • namestringRequiredWhat the firm calls this pack. Unique within the firm.

Responses

  • 201DocumentPackResponseDto
  • 400An item gave both or neither source, or a non-https link
  • 404One of the documents was not found
  • 409A pack with this name already exists
GET/v1/document-packs/{packId}Read one document pack and its items

Opens with: API key

Parameters

  • packIdstringpathRequiredDocument pack id

Responses

  • 200DocumentPackResponseDto
  • 404Document pack not found
PATCH/v1/document-packs/{packId}Rename a pack, or replace its items

Sending `items` replaces the whole list; omitting it leaves the items alone. Editing a pack never changes an envelope or a proposal already created from it: the items were copied at instantiation, not referenced.

Opens with: API key

Parameters

  • packIdstringpathRequiredDocument pack id

Request body: UpdateDocumentPackDto

  • descriptionstringA note to the colleague choosing between packs
  • itemsDocumentPackItemInputDto[]The complete new item list, in order. Sent, it REPLACES every item on the pack; omitted, the items are left alone. Editing a pack never reaches an envelope or a proposal that has already been created from it: materialisation copies, it does not reference.
  • namestringWhat the firm calls this pack

Responses

  • 200DocumentPackResponseDto
  • 400An item gave both or neither source, or a non-https link
  • 404Document pack, or one of the documents, not found
  • 409A pack with this name already exists
DELETE/v1/document-packs/{packId}Delete a document pack

Templates pointing at it simply stop carrying a pack, and every envelope ever built from one keeps the copy it was given. No document is deleted.

Opens with: API key

Parameters

  • packIdstringpathRequiredDocument pack id

Responses

  • 204Deleted
  • 404Document pack not found
POST/v1/document-packs/{packId}/itemsAdd one item to a document pack

Opens with: API key

Parameters

  • packIdstringpathRequiredDocument pack id

Request body: AddDocumentPackItemDto

  • descriptionstringOne line of context, shown under the label
  • documentIdstringA document from this firm's library. Verified against the caller's tenant AND their department scope before it is accepted. Mutually exclusive with externalUrl.
  • externalUrlstringAn https URL the firm already publishes on its own site. Mutually exclusive with documentId. An external link is never tracked: the request goes to the firm's own server and never reaches us.
  • labelstringRequiredWhat the recipient sees. Not the filename: "Our privacy policy" reads better to a client than "privacy-policy-v4-FINAL.pdf".
  • ordernumberPosition in the list. Omit and it is appended to the end.

Responses

  • 201DocumentPackResponseDto
  • 400Both or neither source given, or a non-https link
  • 404Document pack or document not found
DELETE/v1/document-packs/{packId}/items/{itemId}Remove one item from a document pack

Opens with: API key

Parameters

  • packIdstringpathRequiredDocument pack id
  • itemIdstringpathRequiredPack item id

Responses

  • 200DocumentPackResponseDto
  • 404Document pack or item not found
PUT/v1/document-packs/{packId}/items/orderReorder a pack's items

Takes every item id on the pack, in the order they should appear. A partial list is refused rather than applied.

Opens with: API key

Parameters

  • packIdstringpathRequiredDocument pack id

Request body: ReorderDocumentPackItemsDto

  • idsstring[]RequiredEvery item id on this pack, in the order they should appear. Ids that do not belong here are refused rather than ignored.

Responses

  • 200DocumentPackResponseDto
  • 400The list is incomplete, or names something else
  • 404Document pack not found

Documents

GET/v1/documentsList all documents

Opens with: API key

Parameters

  • pagenumberqueryPage number (1-indexed)
  • pageSizenumberqueryPage size
  • statusPROCESSING | READY | ERROR | DELETEDqueryFilter by status
  • searchstringquerySearch by name
  • sortrecent | newest | oldest | name-asc | name-descqueryResult ordering. Exists because the pickers sort, and they now paginate server-side: sorting a single page in the browser reorders ten rows out of a hundred and calls it sorted. `recent` puts the documents that last went out first (most recent send first), then the never-sent ones newest first. It is what a picker wants at the top: the letter that goes out weekly, not whatever was uploaded last.
  • includeArchivedbooleanqueryInclude archived documents. Off by default — archiving exists to get superseded editions out of the way, so they have to be out of the way unless asked for.
  • categoryIdstringqueryFilter to one category. The literal string `none` returns UNCATEGORISED documents — a real chip in the UI, and not expressible as a UUID.

Responses

  • 200DocumentListResponseDtoList of documents
  • 401Unauthorized
POST/v1/documentsUpload a new document

Opens with: API key

Request body

  • filebinaryRequiredDocument file (PDF, DOCX, PNG, JPEG)
  • namestringCustom document name (optional)

Responses

  • 201UploadResponseDtoDocument uploaded successfully
  • 400Invalid file type or size
  • 401Unauthorized
GET/v1/documents/{id}Get document by ID

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200DocumentResponseDtoDocument details
  • 404Document not found
PATCH/v1/documents/{id}Rename, archive or restore a document

Archiving hides a document from the library and both pickers. It does NOT detach it from envelopes or templates that already carry it, and a draft that already references it still sends.

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Request body: UpdateDocumentDto

  • archivedbooleanArchive (true) or restore (false). Archiving hides the document from the library and both pickers; it does not detach it from envelopes or templates that already carry it.
  • categoryIdobjectMove to a category. Pass `null` to return it to Uncategorised. Omit to leave unchanged.
  • namestringNew display name

Responses

  • 200DocumentResponseDtoUpdated document
  • 400Empty name
  • 404Document not found
DELETE/v1/documents/{id}Delete a document

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 204Document deleted
  • 403Document is in use
  • 404Document not found
GET/v1/documents/{id}/contentGet document content for viewing (inline)

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200Document file stream for inline viewing
  • 404Document not found
GET/v1/documents/{id}/detailsGet document with pages and fields

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200DocumentWithPagesDtoDocument with pages and fields
  • 404Document not found
GET/v1/documents/{id}/downloadDownload document content

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200Document file stream
  • 404Document not found
GET/v1/documents/{id}/fieldsGet fields for a document scoped to a template or envelope.

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200FieldResponseDto[]Scoped fields
  • 400Missing or conflicting scope
  • 404Document not found
POST/v1/documents/{id}/fieldsAdd a field to document (scoped to a template or envelope)

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Request body: CreateFieldDto

  • gridGroupIdstringGrid group this field belongs to (client-supplied GridGroup id).
  • groupKeystringGroup key for RADIO fields. All options in the same radio group share this key.
  • heightnumberRequiredHeight (0-1 normalized)
  • labelstringField label
  • linkKeystringLink key for Field Linking. Fields sharing a non-null linkKey propagate values to one another during signing. Distinct from groupKey (RADIO grouping).
  • multilinebooleanFor TEXT fields: allow multiple lines. Signers get a textarea and the completed PDF word-wraps the value instead of shrinking it onto one line.
  • optionLabelstringOption label/value for RADIO fields (the text shown next to the option).
  • pageNumbernumberRequiredPage number (1-indexed)
  • placeholderstringPlaceholder text
  • recipientRolestringRecipient role this field is assigned to
  • requiredbooleanIs field required
  • typeSIGNATURE | INITIALS | DATE_SIGNED | TEXT | CHECKBOX | DROPDOWN | ATTACHMENT | RADIO | DATERequiredField type
  • validationobjectValidation rules (JSON)
  • widthnumberRequiredWidth (0-1 normalized)
  • xnumberRequiredX position (0-1 normalized)
  • ynumberRequiredY position (0-1 normalized)

Responses

  • 201FieldResponseDtoField created
  • 400Invalid field data or missing/conflicting scope
  • 404Document not found
PUT/v1/documents/{id}/fields/{fieldId}Update a field within a template or envelope scope

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID
  • fieldIdstringpathRequiredField ID

Request body: UpdateFieldDto

  • groupKeystringGroup key for RADIO fields
  • heightnumberHeight (0-1 normalized)
  • labelstringField label
  • multilinebooleanFor TEXT fields: allow multiple lines (textarea + word-wrapped PDF output).
  • optionLabelstringOption label for RADIO fields
  • placeholderstringPlaceholder text
  • recipientRolestringRecipient role
  • requiredbooleanIs field required
  • widthnumberWidth (0-1 normalized)
  • xnumberX position (0-1 normalized)
  • ynumberY position (0-1 normalized)

Responses

  • 200FieldResponseDtoField updated
  • 404Document or field not found
DELETE/v1/documents/{id}/fields/{fieldId}Delete a field within a template or envelope scope

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID
  • fieldIdstringpathRequiredField ID

Responses

  • 204Field deleted
  • 404Document or field not found
POST/v1/documents/{id}/fields/bulkAdd multiple fields to a document (scoped to a template or envelope)

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Request body: BulkCreateFieldsDto

  • fieldsCreateFieldDto[]RequiredArray of fields to create

Responses

  • 201FieldResponseDto[]Fields created
  • 400Invalid field data or missing/conflicting scope
  • 404Document not found
POST/v1/documents/{id}/fields/replaceAtomically replace fields for a (document, owner) pair. Editors call this with the full desired field set.

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Request body: ReplaceFieldsDto

  • envelopeIdstringEnvelope that owns these field placements
  • fieldsCreateFieldDto[]RequiredFull set of fields to persist for this (document, owner) pair. Replaces existing.
  • gridGroupsGridGroupDto[]Full set of grid groups to persist for this (document, owner) pair. Replaces existing.
  • templateIdstringTemplate that owns these field placements

Responses

  • 200FieldResponseDto[]Fields after replacement
  • 400Invalid scope or field data
  • 404Document/template/envelope not found
GET/v1/documents/{id}/grid-groupsGet grid groups for a document scoped to a template or envelope.

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200GridGroupResponseDto[]Scoped grid groups
  • 400Missing or conflicting scope
  • 404Document not found
GET/v1/documents/{id}/pages/{pageNumber}Get page thumbnail URL

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID
  • pageNumbernumberpathRequiredPage number (1-indexed)

Responses

  • 200objectPage thumbnail URL
  • 400Invalid page number
  • 404Document not found
GET/v1/documents/recentRecently used documents

The documents this caller reached for most recently, newest first — the ranking signal a picker actually wants. READY uploads only; drafts do not count as use.

Opens with: API key

Responses

  • 200DocumentResponseDto[]Recently used documents, most recent first
  • 401Unauthorized

Envelopes

GET/v1/envelopesList envelopes

Opens with: API key

Parameters

  • cursorstringqueryCursor for pagination (use nextCursor from previous response)
  • limitnumberqueryNumber of items to return (max 100)
  • pagenumberqueryDeprecatedPage number (1-indexed) - DEPRECATED: Use cursor pagination instead
  • pageSizenumberqueryDeprecatedPage size - DEPRECATED: Use limit instead
  • statusDRAFT | SENT | VIEWED | PARTIALLY_SIGNED | COMPLETED | DECLINED | VOIDED | EXPIRED[]queryFilter by status. Repeat the parameter for several — `?status=SENT&status=VIEWED`. Matches any of the given statuses.
  • searchstringquerySearch by subject or recipient email
  • folderIdstringqueryFilter by folder ID. Use "null" to get envelopes not in any folder.
  • tagstringqueryFilter by tag. Use "null" for envelopes with no tag.
  • createdFromdate-timequeryOnly envelopes created on or after this instant.
  • createdTodate-timequeryOnly envelopes created on or before this instant.
  • contactEmailstringqueryFilter to envelopes with a recipient at this email address (case-insensitive). This is how a client is matched — recipients carry no contact ID.
  • sourceDIRECT | PROPOSAL_ACCEPTANCE | SELF_SIGNqueryFilter by provenance. `DIRECT` for envelopes composed in the app, `PROPOSAL_ACCEPTANCE` for those spawned by a client accepting a proposal, `SELF_SIGN` for documents the sender signed alone. Omit for `DIRECT` only: `SELF_SIGN` was never sent to anybody, and `PROPOSAL_ACCEPTANCE` belongs to its proposal, so both are excluded unless named.
  • includeAllbooleanqueryInclude every source, hidden ones too. Ignored when `source` is given. Omit for the default, which is `DIRECT` only.
  • mineOnlybooleanqueryOnly envelopes created by the calling user. Narrows the caller's existing scope and never widens it.

Responses

  • 200EnvelopeListResponseDtoReturns list of envelopes
POST/v1/envelopesCreate a new envelope

Opens with: API key

Parameters

  • Idempotency-KeystringheaderUnique key for idempotent requests

Request body: CreateEnvelopeDto

  • departmentIdstringWhich department this envelope belongs to. Omit and it resolves from the creator: their single department, or the company-wide default when they belong to none or several. Must be one the caller may file into (GET /v1/departments/assignable) — anything else is a 400, never a silent fallback.
  • documentIdsstring[]RequiredDocument IDs to include in the envelope
  • expiresAtdate-timeExpiration date for the envelope
  • folderIdstringFolder ID to assign the envelope to
  • idempotencyKeystringIdempotency key for this request. Can also be provided via the Idempotency-Key header.
  • messagestringMessage to recipients
  • metadataobjectCustom metadata
  • recipientsCreateRecipientDto[]RequiredRecipients of the envelope
  • signingOrderPARALLEL | SEQUENTIALSigning order type
  • subjectstringRequiredEnvelope subject (email subject line)
  • templateIdstringTemplate ID if creating from a template

Responses

  • 201EnvelopeDetailResponseDtoEnvelope created successfully
  • 400Invalid input, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header
  • 404Document(s) not found
GET/v1/envelopes/{id}Get envelope by ID

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200EnvelopeDetailResponseDtoReturns envelope details
  • 404Envelope not found
PATCH/v1/envelopes/{id}Edit a draft envelope

Change a DRAFT in place: subject, message, signing order, expiry, folder, and the complete set of documents and recipients (each replaces the current set when present; an omitted field is unchanged). A recipient row that names the id it replaces and omits its access code keeps the stored one. Field placements are edited through the document field routes with envelopeId. Only a DRAFT accepts this; a sent envelope is voided or resent, and a proposal acceptance or self-sign envelope is refused.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateEnvelopeDto

  • documentIdsstring[]The complete, ordered set of document IDs. Replaces the current set.
  • expiresAtobjectExpiration date. Null clears it; omitted leaves it unchanged.
  • folderIdobjectFolder to file the envelope into. Null removes it from its folder.
  • messagestringMessage to recipients. An empty string clears it.
  • recipientsUpdateRecipientDto[]The complete set of recipients. Replaces the current set.
  • signingOrderPARALLEL | SEQUENTIALSigning order type
  • subjectstringEnvelope subject (email subject line)

Responses

  • 200EnvelopeDetailResponseDtoDraft updated
  • 400Invalid input, or a document is not ready
  • 404Envelope, document or folder not found
  • 409The envelope is not a draft, or cannot be edited
DELETE/v1/envelopes/{id}Delete a draft envelope

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 204Envelope deleted successfully
  • 404Envelope not found
  • 409Only draft envelopes can be deleted
GET/v1/envelopes/{id}/auditGet envelope audit trail

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Returns chronological audit events with hash chain verification
  • 404Envelope not found
GET/v1/envelopes/{id}/audit/anchorsList and verify timestamp anchors

Every batch this envelope is included in, the providers that timestamped each batch, and a re-verification of the inclusion proof against the root those providers signed. An envelope with no anchors returns anchored:false and verified:true — absence of an anchor is an absent claim, not a failed check, and every envelope completed before 2026-08-06 has none.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Anchor verification results
  • 404Envelope not found
GET/v1/envelopes/{id}/audit/verifyVerify audit chain integrity

Verifies the hash chain, cross-checks any timestamp anchor against the chain as it stands now, and reconciles the trail against the envelope and recipient statuses recorded outside it. Returns the breakpoint on a hash failure and a typed finding for each contradiction. An unanchored envelope is verified:true with anchored:false — absence of an anchor is an absent claim, not a failed check.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Chain verification result
  • 404Envelope not found
GET/v1/envelopes/{id}/certificateGet certificate of completion

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Returns PDF certificate
  • 404Envelope not found
GET/v1/envelopes/{id}/copiesEvery copy sent for this envelope

Who it went to, when, whether it has been opened and whether it is still live. This is the half of the feature a PDF on a shared drive can never give you.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200EnvelopeCopyListResponseDto
  • 404Envelope not found
POST/v1/envelopes/{id}/copiesEmail a copy of a completed document to one address

Mints a scoped, expiring, revocable link and emails it. The document is never attached and the link is never a storage URL: every read is served through our own API, recorded against the link, and refused once the link is withdrawn or the envelope is voided. Session callers only.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: SendEnvelopeCopyDto

  • emailstringRequiredThe one address this copy is for. One per send, deliberately: a send is an act with a named recipient, and a list would make withdrawing it all-or-nothing.
  • notestringAn optional line from the sender, shown in the email as an attributed quote under their name. It is NOT an email body: URLs are stripped and the text is capped before it is stored, because the email already carries exactly one destination and a second one inside a sender's words is indistinguishable from ours to the reader.

Responses

  • 201EnvelopeCopyResponseDto
  • 400Invalid address, or this firm has reached its hourly limit for copies
  • 403An API key cannot send a copy
  • 404Envelope not found
  • 409The envelope is not completed, or its signed document is not ready yet
POST/v1/envelopes/{id}/copies/{copyId}/revokeWithdraw a copy link

The link stops working immediately. Withdrawing one that is already withdrawn is not an error, so pressing the button twice on a stale list does not report a failure over a link that is in exactly the state the caller asked for.

Opens with: API key

Parameters

  • idstringpathRequired
  • copyIdstringpathRequired

Responses

  • 200The link is withdrawn
  • 404Envelope or copy link not found
GET/v1/envelopes/{id}/documents/{documentId}/downloadDownload envelope document content

Opens with: API key

Parameters

  • idstringpathRequired
  • documentIdstringpathRequired

Responses

  • 200Document file stream
  • 404Envelope or document not found
GET/v1/envelopes/{id}/download/signed-mergedDownload all signed documents merged into a single PDF

Returns the envelope-level merged signed PDF (all documents combined).

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Merged signed PDF stream
  • 404Envelope not completed yet
POST/v1/envelopes/{id}/duplicateDuplicate an envelope

Create a copy of an envelope as a new draft with the same documents and recipients

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 201DuplicateEnvelopeResponseDtoEnvelope duplicated successfully
  • 400Invalid input, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header
  • 404Envelope not found
GET/v1/envelopes/{id}/evidenceGet evidence package (ZIP)

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Returns ZIP file
  • 404Envelope not found
POST/v1/envelopes/{id}/folderUpdate envelope folder

Move an envelope to a folder or remove from folder

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateEnvelopeFolderDto

  • folderIdobjectRequiredFolder ID to move envelope to (null to remove from folder)

Responses

  • 200Folder updated
  • 404Envelope or folder not found
POST/v1/envelopes/{id}/quick-signQuick Sign: apply your saved signature to all your fields

Auto-applies the current user's saved signature to every signature/initials/date field assigned to them on this envelope. Only allowed when the user is a pending recipient and (for sequential envelopes) is the active signer. Fails if the user has any required TEXT/CHECKBOX/DROPDOWN/RADIO fields that need manual input.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: QuickSignEnvelopeDto

  • consentbooleanRequiredConsent to sign electronically. Must be true.
  • consentVersionstringId of the consent statement the client displayed, e.g. "quick-sign/v1". The wording is resolved server-side from that id and never taken from the request; this only says which of our own published statements was on screen. Omit and the current one is recorded.
  • initialsIdstringSaved initials ID to use (when the recipient has INITIALS fields). Defaults to the user's default saved initials.
  • signatureIdstringSaved signature ID to use. Defaults to the user's default saved signature.

Responses

  • 200QuickSignResponseDtoSigned
  • 400Cannot quick-sign (validation failed)
  • 404Envelope not found or not awaiting you
POST/v1/envelopes/{id}/recipients/{recipientId}/embedGenerate embedded signing URL

Generate a short-lived URL for embedding signing in an iframe. The URL expires in 5 minutes and can only be used once.

Opens with: API key

Parameters

  • idstringpathRequired
  • recipientIdstringpathRequired

Responses

  • 200Returns embeddable signing URL
  • 403Feature not available on current plan
  • 404Envelope or recipient not found
  • 409Recipient already signed or envelope not in signable state
PATCH/v1/envelopes/{id}/recipients/{recipientId}/mark-notifiedMark recipient as manually notified

Mark a LINK_ONLY recipient as having been notified manually (e.g., after copying and sharing the signing link).

Opens with: API key

Parameters

  • idstringpathRequired
  • recipientIdstringpathRequired

Responses

  • 200Recipient marked as notified
  • 404Envelope or recipient not found
POST/v1/envelopes/{id}/remindSend reminder to pending recipients

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Reminders sent
  • 404Envelope not found
  • 409Cannot send reminders for envelope in current state
POST/v1/envelopes/{id}/resend-with-answersResend a finished envelope pre-filled with previous answers

For a completed or declined envelope, creates and sends a fresh envelope pre-filled with the data signers entered last time (text, dates, checkboxes, radios). Signatures, initials, signing dates and attachments are left blank so signers review and re-sign. The original envelope is preserved as the record.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: ResendWithAnswersDto

  • messagestringOptional note to the signers explaining what to correct. Becomes the message on the resent envelope and is shown to recipients. Falls back to the original message if omitted.

Responses

  • 201ResendWithAnswersResponseDtoEnvelope resent successfully
  • 400Invalid input, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header
  • 404Envelope not found
  • 409Envelope is not completed or declined
POST/v1/envelopes/{id}/save-as-templateSave an envelope as a template

Creates a template from this envelope: its documents in order, its recipients as numbered roles (signer_1, viewer_1, ...), its field placements, its signing order and its message. No recipient email or expiry is carried across. Refused for a proposal acceptance envelope.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: CreateTemplateFromEnvelopeDto

  • categorystringTemplate category for grouping
  • descriptionstringTemplate description
  • namestringRequiredTemplate name

Responses

  • 201TemplateDetailResponseDtoTemplate created
  • 400The envelope is a proposal acceptance, has no documents, or a document is gone
  • 404Envelope not found
POST/v1/envelopes/{id}/sendSend envelope to recipients

Opens with: API key

Parameters

  • idstringpathRequired

Request body: SendEnvelopeDto

  • expiresAtdate-timeOverride expiration date

Responses

  • 200SendEnvelopeResponseDtoEnvelope sent successfully
  • 400Invalid envelope state or missing fields
  • 404Envelope not found
  • 409Envelope already sent
PUT/v1/envelopes/{id}/tagSet or clear the tag on an envelope

Files an envelope under a single optional label, at any status. FILING, NOT EVIDENCE: it writes no audit event, never reaches the signed PDF or the certificate, and changing it changes nothing a counterparty relies on. An empty or whitespace-only value clears the tag, as does null. A self-sign envelope may only be tagged by the person who created it, which is the same privacy rule `mineOnly` applies to the list.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: SetEnvelopeTagDto

  • tagobjectRequiredThe tag, or null to remove it. Trimmed; an empty or whitespace-only string is treated as null, so clearing the box in the UI and sending null mean the same thing.

Responses

  • 200EnvelopeResponseDtoTag set
  • 404Envelope not found
POST/v1/envelopes/{id}/voidVoid an envelope

Opens with: API key

Parameters

  • idstringpathRequired

Request body: VoidEnvelopeDto

  • reasonstringRequiredReason for voiding the envelope

Responses

  • 200VoidEnvelopeResponseDtoEnvelope voided successfully
  • 404Envelope not found
  • 409Cannot void envelope in current state
GET/v1/envelopes/awaiting-meList envelopes awaiting the current user's signature

Returns envelopes where the current logged-in user is a pending signer. For sequential envelopes, only the actively-pending wave is returned.

Opens with: API key

Responses

  • 200AwaitingMeListResponseDtoList of envelopes awaiting current user
POST/v1/envelopes/bulk-moveBulk move envelopes to folder

Move multiple envelopes to a folder or remove from folder

Opens with: API key

Request body: BulkMoveEnvelopesDto

  • envelopeIdsstring[]RequiredEnvelope IDs to move
  • folderIdobjectRequiredFolder ID to move envelopes to (null to remove from folder)

Responses

  • 200Envelopes moved
  • 404Folder not found
POST/v1/envelopes/preview-emailRender the covering email for an envelope being composed

Opens with: API key

Request body: EmailPreviewRequestDto

  • expiresAtobjectISO date, or null.
  • messagestringThe covering note, plain text, possibly carrying references.
  • recipientEmailstringRequiredWhich recipient this preview is for. Everyone gets their own links.
  • recipientNamestring
  • subjectstringRequiredThe envelope subject, which becomes the email subject line.
  • supportingEmailPreviewSupportingDto[]

Responses

  • 200EmailPreviewResponseDto
POST/v1/envelopes/self-signCreate a self-sign envelope

Create an envelope where you are the sole signer. Returns a signing URL to sign immediately.

Opens with: API key

Request body: CreateSelfSignEnvelopeDto

  • consentbooleanSet true to sign the document in the same call. Every placement carrying a `value` is filled and the envelope is completed. Consent is required to sign and is recorded in full on the audit trail. Omit to create an unsigned envelope and sign it separately.
  • consentVersionstringId of the consent statement the interface displayed, e.g. "quick-sign/v1". The wording is resolved server-side from this id and never read from the request. Omit and the current statement is recorded.
  • departmentIdstringWhich department this envelope belongs to. Omit and it resolves from the creator: their single department, or the company-wide default when they belong to none or several. Must be one the caller may file into (GET /v1/departments/assignable) — anything else is a 400, never a silent fallback.
  • documentIdsstring[]RequiredDocument IDs to include in the envelope
  • fieldsSelfSignFieldDto[]Field placements for this envelope, in fractional (0-1) page geometry. Fields cannot be added after creation — a self-sign envelope is created already SENT and placements are frozen from that point. Omit to create an envelope with no fields.
  • gridGroupsSelfSignGridGroupDto[]Grid tables referenced by the `gridGroupId` on any grid cell in `fields`.
  • metadataobjectCustom metadata
  • subjectstringRequiredDocument name/subject for the self-signed document

Responses

  • 201SelfSignEnvelopeResponseDtoSelf-sign envelope created. Use signingUrl to sign.
  • 400Invalid input, documents not ready, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header
  • 404Document(s) not found
GET/v1/envelopes/statsEnvelope status counts for the list’s tile row

Counts of drafts, envelopes out for signature, completed and expired, plus the overall total — over the SAME filters and the SAME visibility scope as `GET /envelopes`. Takes every list filter except `status`, which it strips, so the tiles describe the filtered set independently of which status is selected and each one remains clickable. The four buckets deliberately do not sum to `total`: DECLINED and VOIDED are counted in `total` and in no bucket. NOTE: nothing in this codebase writes the EXPIRED status — there is no envelope expiry sweep — so `expired` reflects only seeded or out-of-band rows.

Opens with: API key

Parameters

  • searchstringquerySearch by subject or recipient email, identical to the list endpoint’s
  • folderIdstringqueryFilter by folder ID. Use "null" for envelopes not in any folder.
  • contactEmailstringqueryFilter to one client by recipient email, identical to the list endpoint's
  • sourceDIRECT | PROPOSAL_ACCEPTANCE | SELF_SIGNqueryFilter by provenance, identical to the list endpoint's
  • includeAllbooleanqueryInclude every source, hidden ones too. Ignored when `source` is given. Omit for the default, which is `DIRECT` only.

Responses

  • 200EnvelopeStatsResponseDto
  • 400Invalid query params
GET/v1/envelopes/tagsTags this user has used

Every distinct tag on the calling user’s own envelopes, alphabetical. Derived from the rows rather than stored, so there is no vocabulary to administer and nothing to seed. Scoped to the caller rather than the department: the only surface using it is the self-sign list, which is already narrowed to its owner.

Opens with: API key

Responses

  • 200EnvelopeTagsResponseDto

Field Linking

POST/v1/fields/{id}/unlinkClear a field’s linkKey (unlink it from its group)

Opens with: API key

Parameters

  • idstringpathRequiredField ID

Responses

  • 200FieldResponseDto
POST/v1/fields/linkLink fields under a shared linkKey (value propagation)

Opens with: API key

Request body: LinkFieldsDto

  • fieldIdsstring[]RequiredIDs of the fields to link (at least two, same envelope or same template)
  • linkKeystringShared link key. If omitted, a unique key is generated (or an existing key already on one of the fields is reused).

Responses

  • 200LinkFieldsResponseDto
  • 400Fewer than two fields, or mixed scope

Field Suggestions

GET/v1/documents/{id}/suggestionsList PENDING smart field suggestions for a document

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200FieldSuggestionResponseDto[]
POST/v1/documents/{id}/suggestions/accept-allAccept all PENDING suggestions for a document

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Request body: SuggestionScopeDto

  • envelopeIdstringEnvelope ID (mutually exclusive with templateId)
  • templateIdstringTemplate ID (mutually exclusive with envelopeId)

Responses

  • 200AcceptSuggestionResponseDto
POST/v1/documents/{id}/suggestions/detectRun smart-field detection on demand and (re)write PENDING suggestions

Synchronous re-run of the upload-time detection job. Idempotent: existing PENDING suggestions are cleared and re-created; ACCEPTED/REJECTED rows are preserved. A detector load/parse failure surfaces as a 500 rather than a false empty result.

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Request body: DetectSuggestionsRequestDto

  • forcebooleanReserved for future use; has no effect on current behaviour

Responses

  • 200DetectSuggestionsResponseDto
  • 404Document not found
  • 500Detection failed (could not analyse the document)
POST/v1/documents/{id}/suggestions/reject-allReject all PENDING suggestions for a document

Opens with: API key

Parameters

  • idstringpathRequiredDocument ID

Responses

  • 200SuggestionActionResultDto
POST/v1/suggestions/{id}/acceptAccept a suggestion (promotes to a real Field; link group accepts together)

Opens with: API key

Parameters

  • idstringpathRequiredSuggestion ID

Request body: SuggestionScopeDto

  • envelopeIdstringEnvelope ID (mutually exclusive with templateId)
  • templateIdstringTemplate ID (mutually exclusive with envelopeId)

Responses

  • 200AcceptSuggestionResponseDto
  • 400Already actioned or invalid scope
POST/v1/suggestions/{id}/rejectReject a suggestion

Opens with: API key

Parameters

  • idstringpathRequiredSuggestion ID

Responses

  • 200FieldSuggestionResponseDto

Folders

GET/v1/foldersGet folder tree

Get the complete folder tree with envelope counts

Opens with: API key

Responses

  • 200Returns folder tree
POST/v1/foldersCreate folder

Create a new folder

Opens with: API key

Request body: CreateFolderDto

  • colorstringHex color for folder icon (e.g., #3B82F6)
  • namestringRequiredFolder name
  • parentIdstringParent folder ID for nesting

Responses

  • 201Folder created
  • 404Parent folder not found
  • 409Folder with this name already exists
GET/v1/folders/{id}Get folder

Get a single folder by ID

Opens with: API key

Parameters

  • idstringpathRequiredFolder ID

Responses

  • 200Returns folder
  • 404Folder not found
PATCH/v1/folders/{id}Update folder

Update a folder (name, parent, color)

Opens with: API key

Parameters

  • idstringpathRequiredFolder ID

Request body: UpdateFolderDto

  • colorstringHex color for folder icon
  • namestringFolder name
  • parentIdobjectParent folder ID (null to move to root)

Responses

  • 200Folder updated
  • 404Folder not found
  • 409Folder with this name already exists
DELETE/v1/folders/{id}Delete folder

Delete a folder. Child folders and envelopes are moved to the parent.

Opens with: API key

Parameters

  • idstringpathRequiredFolder ID

Responses

  • 200Folder deleted
  • 404Folder not found
POST/v1/folders/{id}/moveMove folder

Move a folder to a new parent (or root)

Opens with: API key

Parameters

  • idstringpathRequiredFolder ID

Request body: MoveFolderDto

  • newParentIdobjectNew parent folder ID (null to move to root)

Responses

  • 200Folder moved
  • 400Invalid move (circular reference)
  • 404Folder not found
GET/v1/folders/{id}/pathGet folder path

Get the breadcrumb path from root to this folder

Opens with: API key

Parameters

  • idstringpathRequiredFolder ID

Responses

  • 200Returns folder path
  • 404Folder not found
GET/v1/folders/listList all folders

Get all folders as a flat list

Opens with: API key

Responses

  • 200Returns flat folder list

Health

GET/v1/healthHealth check endpoint

Opens with: No credential.

Responses

  • 200Service is healthy

Notifications

GET/v1/notificationsGet notifications

Get all notifications for the current user

Opens with: API key

Parameters

  • limitnumberqueryMaximum number of notifications to return (default: 20)
  • offsetnumberqueryNumber of notifications to skip (default: 0)
  • unreadOnlybooleanqueryOnly return unread notifications (default: false)

Responses

  • 200List of notifications
DELETE/v1/notificationsDelete all notifications

Delete all notifications for the current user

Opens with: API key

Responses

  • 200All notifications deleted
DELETE/v1/notifications/{id}Delete a notification

Delete a specific notification

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 204Notification deleted
POST/v1/notifications/{id}/readMark notification as read

Mark a specific notification as read

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Notification marked as read
  • 404Notification not found
GET/v1/notifications/countGet unread count

Get the number of unread notifications

Opens with: API key

Responses

  • 200Unread notification count
POST/v1/notifications/read-allMark all notifications as read

Mark all notifications for the current user as read

Opens with: API key

Responses

  • 200All notifications marked as read

Onboarding

GET/v1/onboarding/preferencesGet the current user's onboarding tour preferences

Returns whether the user has permanently opted out of automatic walkthroughs. Also included in GET /onboarding/tours; exposed separately for UI that only needs the toggle.

Opens with: API key

Responses

  • 200OnboardingPreferencesResponseDtoTour preferences
  • 401Unauthorized
PUT/v1/onboarding/preferencesTurn automatic onboarding tours off (or back on) for the current user

Sets the account-wide opt-out behind the "Don't show tours again" checkbox. When toursDisabled is true, no tour auto-runs again for this user — including tours whose version is later bumped, which is the point: a version bump must never override an explicit opt-out. Tours stay replayable on demand from the Help menu. Idempotent.

Opens with: API key

Request body: UpdateOnboardingPreferencesDto

  • toursDisabledbooleanRequiredTrue to stop showing walkthroughs automatically, false to re-enable them. Idempotent — re-sending the same value keeps the original opt-out timestamp.

Responses

  • 200OnboardingPreferencesResponseDtoUpdated tour preferences
  • 401Unauthorized
GET/v1/onboarding/setupFirst-run setup checklist for the current account

Every item is computed from live data on each request rather than stored, so it cannot disagree with the account. `required` marks the items that BLOCK something (company identity, and the per-user proposals grant on an entitled plan); the rest are encouragement. Proposals items appear only for a tenant whose plan includes the module.

Opens with: API key

Responses

  • 200SetupChecklistResponseDtoChecklist
  • 401Unauthorized
GET/v1/onboarding/toursList onboarding tour state for the current user

Returns one row per tour the user has completed or dismissed, plus the account-wide tour preferences. The web client compares completedVersion against its TOUR_VERSIONS constants to decide whether to re-trigger a tour — unless preferences.toursDisabled is true, which suppresses all automatic tours regardless of version.

Opens with: API key

Responses

  • 200TourStateListResponseDtoTour state list
  • 401Unauthorized
DELETE/v1/onboarding/tours/{key}Reset an onboarding tour for the current user

Deletes the user's state for this tour key, causing it to re-trigger on next visit. Used by the "Restart tour" action.

Opens with: API key

Parameters

  • keystringpathRequiredTour key

Responses

  • 204Tour state cleared
  • 401Unauthorized
POST/v1/onboarding/tours/{key}/completeMark an onboarding tour as completed

Records that the user has finished the tour at the given version. Upserts on (userId, tourKey).

Opens with: API key

Parameters

  • keystringpathRequiredTour key (e.g. "sidebar", "dashboard")

Request body: CompleteTourDto

  • versionnumberRequiredThe version of the tour the user just completed. Used to detect when a tour has been updated and should re-trigger.

Responses

  • 200TourStateResponseDtoTour state
  • 401Unauthorized
POST/v1/onboarding/tours/{key}/dismissMark an onboarding tour as dismissed

Records that the user dismissed/skipped the tour at the given version. A skip earns a one-version grace in the web client (see TOUR_SKIP_GRACE in apps/web/lib/tours) — the next bump does not re-ask, the one after that does.

Opens with: API key

Parameters

  • keystringpathRequiredTour key

Request body: DismissTourDto

  • versionnumberRequiredThe version of the tour the user dismissed. Recorded so the same version does not re-trigger.

Responses

  • 200TourStateResponseDtoTour state
  • 401Unauthorized

Proposal Templates

GET/v1/proposal-templatesList proposal templates

Paginated list, active templates only by default. Pass isActive=false for the archive, or includeArchived=true for both. The tenant default sorts first.

Opens with: API key

Parameters

  • pagenumberquery1-based page (default 1)
  • pageSizenumberqueryPage size (default 20, max 100)
  • isActivebooleanqueryFilter by soft-delete state. Defaults to true (active only); pass false for the archive, or omit the filter entirely with includeArchived.
  • includeArchivedbooleanqueryReturn active AND archived templates. Overrides isActive when true.

Responses

  • 200ProposalTemplateListResponse
  • 400Invalid query params
POST/v1/proposal-templatesCreate a proposal template

Creates a template with optional nested service lines. currency and defaultVatRate are seeded from the tenant company profile when omitted; a request with no currency on either side is rejected.

Opens with: API key

Request body: CreateProposalTemplateDto

  • bodyContentstringRequiredEngagement-letter / proposal body (rich HTML)
  • bodyTitlestringBody title (default "Engagement Letter")
  • coverLetterContentobjectCover-letter rich HTML (supports {{merge}} tokens)
  • currencystringISO 4217 currency code. Defaults to the tenant company profile currency; a 400 is returned if neither is set.
  • defaultAcceptanceModeSIGNATUREHow the client accepts. `SIGNATURE` is the only supported value — acceptance is always a real e-signature ceremony, so it inherits the hash-chained audit trail and completion certificate. `CLICK` exists in the schema for historical rows and is rejected here.
  • defaultPaymentTermsobjectDefault payment terms text
  • defaultValidityDaysnumberDefault validity window in days (default 30)
  • defaultVatInclusivebooleanWhether entered amounts are VAT-inclusive (default false)
  • defaultVatRatestringDefault VAT rate (%). Defaults to the tenant company profile rate; send null explicitly for "no VAT".
  • descriptionobjectInternal description
  • documentPackIdobjectA document pack sent alongside every proposal made from this template. Its items are COPIED onto the proposal at creation, so editing the pack afterwards never changes anything already sent. Null clears it.
  • isDefaultbooleanMake this the tenant default template. Setting it clears the flag on every other template for the tenant.
  • namestringRequiredTemplate name
  • servicesCreateProposalTemplateServiceDto[]Default service/fee lines, created in array order when sortOrder is omitted
  • signingTemplateIdobjectSigning (envelope) template used when acceptance spawns a signing envelope. Null ⇒ auto-build a single-signer signature block.
  • termsAndConditionsobjectTerms & conditions rich HTML

Responses

  • 201ProposalTemplateResponse
  • 400No currency on the request or the company profile, or an invalid ESTIMATE range
  • 404Tenant or signing template not found
GET/v1/proposal-templates/{id}Get a proposal template

Includes the template service lines ordered by sortOrder.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID

Responses

  • 200ProposalTemplateResponse
  • 404Proposal template not found
PATCH/v1/proposal-templates/{id}Update a proposal template

Partial update: an absent key is unchanged, an explicit null clears a nullable column. Setting isDefault=true clears the flag on every other template for the tenant. Content changes bump version; isDefault/isActive changes do not.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID

Request body: UpdateProposalTemplateDto

  • bodyContentstringEngagement-letter / proposal body (rich HTML)
  • bodyTitlestringBody title
  • coverLetterContentobjectCover-letter rich HTML
  • currencystringISO 4217 currency code
  • defaultAcceptanceModeSIGNATUREHow the client accepts. `SIGNATURE` is the only supported value — acceptance is always a real e-signature ceremony, so it inherits the hash-chained audit trail and completion certificate. `CLICK` exists in the schema for historical rows and is rejected here.
  • defaultPaymentTermsobjectDefault payment terms text
  • defaultValidityDaysnumberDefault validity window in days
  • defaultVatInclusivebooleanWhether entered amounts are VAT-inclusive
  • defaultVatRatestringDefault VAT rate (%); null clears it ("no VAT")
  • descriptionobjectInternal description
  • documentPackIdobjectA document pack sent alongside every proposal made from this template. Its items are COPIED onto the proposal at creation, so editing the pack afterwards never changes anything already sent. Null clears it.
  • isActivebooleanSoft-delete flag. false archives the template (and clears isDefault); true restores it. DELETE is the normal archive route.
  • isDefaultbooleanMake (or unmake) this the tenant default. Setting true clears the flag on every other template for the tenant; an archived template cannot be the default.
  • namestringTemplate name
  • signingTemplateIdobjectSigning (envelope) template id; null clears it
  • termsAndConditionsobjectTerms & conditions rich HTML

Responses

  • 200ProposalTemplateResponse
  • 400An archived template cannot be made the default
  • 404Proposal template or signing template not found
DELETE/v1/proposal-templates/{id}Archive (soft-delete) a proposal template

Sets isActive=false — the single soft-delete mechanism on this model — and clears isDefault so the default pointer never dangles at an archived template. Idempotent. Restore via PATCH with isActive=true.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID

Responses

  • 200ProposalTemplateActionResponse
  • 404Proposal template not found
POST/v1/proposal-templates/{id}/cloneClone a proposal template

Duplicates the template and all of its service lines (relative order preserved). The copy is active, is never the default, starts at version 1, and is named "<name> (copy)".

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID to clone

Responses

  • 201ProposalTemplateResponse
  • 404Proposal template not found
POST/v1/proposal-templates/{id}/servicesAdd a service line to a template

Appends to the end of the list unless sortOrder is supplied. Bumps the template version.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID

Request body: CreateProposalTemplateServiceDto

  • defaultAmountstringDefault fee. FIXED: the fee. HOURLY: fallback when rate/hours are absent. ESTIMATE: the lower bound.
  • defaultAmountTostringUpper bound of an ESTIMATE range (must be >= defaultAmount)
  • defaultBillingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLYBilling frequency; ONE_OFF feeds the one-off bucket, the rest the recurring bucket
  • defaultEstimatedHoursstringEstimated hours (HOURLY only)
  • defaultFeeTypeFIXED | HOURLY | ESTIMATEFee type (default FIXED)
  • defaultRatestringHourly rate (HOURLY only)
  • defaultVatRatestringThe default VAT rate for this service, as a percentage. OMIT OR SEND NULL TO INHERIT the proposal's rate, which is what most services do. An explicit 0 is NOT the same as null: it means this service carries no VAT and keeps carrying none if the firm later changes its default rate. This is the field a practice sets once so that, for example, a zero-rated filing fee stays zero-rated on every letter it appears on.
  • descriptionobjectWhat the service covers
  • namestringRequiredService name, e.g. "Annual accounts"
  • sortOrdernumberDisplay position. Omit to append (create: position in the submitted array; add: end of the list).
  • sourceServiceIduuidPROVENANCE ONLY — the catalogue service this line was created from, if it came from one. Recorded so the catalogue can report which templates include a service, and carried onto every proposal created from this template. It does NOT link the line to the catalogue: the values above remain the authority, and a later catalogue edit never reaches a template or a proposal through it.

Responses

  • 201ProposalTemplateServiceResponse
  • 400Invalid ESTIMATE range
  • 404Proposal template not found
PATCH/v1/proposal-templates/{id}/services/{serviceId}Update a template service line

Partial update. Bumps the template version.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID
  • serviceIdstringpathRequiredService line ID

Request body: UpdateProposalTemplateServiceDto

  • defaultAmountstringDefault fee
  • defaultAmountTostringUpper bound of an ESTIMATE range
  • defaultBillingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLY
  • defaultEstimatedHoursstringEstimated hours (HOURLY only)
  • defaultFeeTypeFIXED | HOURLY | ESTIMATE
  • defaultRatestringHourly rate (HOURLY only)
  • defaultVatRatestringThe default VAT rate for this service, as a percentage. OMIT OR SEND NULL TO INHERIT the proposal's rate, which is what most services do. An explicit 0 is NOT the same as null: it means this service carries no VAT and keeps carrying none if the firm later changes its default rate. This is the field a practice sets once so that, for example, a zero-rated filing fee stays zero-rated on every letter it appears on.
  • descriptionobjectWhat the service covers
  • namestringService name
  • sortOrdernumberDisplay position

Responses

  • 200ProposalTemplateServiceResponse
  • 400Invalid ESTIMATE range
  • 404Service line not found on this template
DELETE/v1/proposal-templates/{id}/services/{serviceId}Delete a template service line

Hard delete — the line has no soft-delete column and needs none, since proposals snapshot their own service rows. Bumps the template version.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID
  • serviceIdstringpathRequiredService line ID

Responses

  • 200ProposalTemplateActionResponse
  • 404Service line not found on this template
PUT/v1/proposal-templates/{id}/services/reorderReorder a template service lines

Rewrites sortOrder from the submitted order. The list must contain every service line of the template exactly once — partial or foreign lists are rejected rather than partially applied.

Opens with: API key

Parameters

  • idstringpathRequiredProposal template ID

Request body: ReorderProposalTemplateServicesDto

  • serviceIdsstring[]RequiredEvery one of the template's service line ids, exactly once, in the desired display order

Responses

  • 200ProposalTemplateResponse
  • 400Duplicate, foreign, or missing service ids
  • 404Proposal template not found
GET/v1/proposal-templates/merge-tokensMerge tokens available in template content

Groups + every token, in picker display order. The money tokens’ descriptions state the §6.2.1 phrase behaviour, because that is the moment an author needs to know it.

Opens with: API key

Responses

  • 200MergeTokenRegistryResponse

Proposals

GET/v1/proposalsList proposals

Paginated list of a tenant’s proposals. Filterable by status (repeat the parameter for several), contact, department, a free-text search over the reference / title / client, and a last-updated date range — all applied in the database, so `total` describes the filtered set and a match on page 9 is still found. `stillOpen` additionally drops SENT/VIEWED proposals already past their deadline. Orderable by updatedAt, createdAt or proposalRef (default createdAt desc); there is deliberately NO ordering by value, because a proposal’s one-off and recurring amounts cannot be merged into one sortable number (§6.2). `GET /proposals/stats` answers the same filters as counts.

Opens with: API key

Parameters

  • pagenumberquery1-based page (default 1)
  • pageSizenumberqueryPage size (default 20, max 100)
  • statusDRAFT | RENDERING | RENDER_FAILED | SENT | VIEWED | SIGNING | ACCEPTED | DECLINED | EXPIRED | SUPERSEDED | CANCELLED[]queryFilter by status. Repeat the parameter for several — `?status=SENT&status=VIEWED`. Matches any of the given statuses.
  • contactIdstringqueryFilter by contact
  • departmentIdstringqueryOnly proposals filed to this department. Intersected with the caller’s own visibility scope, never substituted for it — naming a department the caller may not see returns nothing rather than widening what they may read.
  • searchstringqueryFree-text search, case-insensitive substring, across the proposal reference ("PROP-020"), the engagement-letter title, and the client’s name, company and email. Applied SERVER-SIDE against the whole tenant, so it finds proposals that are not on the current page — a client-side filter over one page silently cannot.
  • updatedFromstringqueryOnly proposals whose last update is at or after this instant (ISO 8601). Send a real instant, not a bare date: the server cannot know the caller’s timezone, so the caller decides where its own day starts.
  • updatedTostringqueryOnly proposals whose last update is at or before this instant (ISO 8601). Inclusive — send the end of the intended day (23:59:59.999) rather than its midnight.
  • sentFromstringqueryOnly proposals SENT at or after this instant (ISO 8601). Distinct from `updatedFrom`, and the one the analytics drilldowns use: every rate metric cohorts on sentAt, so a drilldown filtered by last-update would land on a different set from the chart that produced the link.
  • sentTostringqueryOnly proposals SENT at or before this instant (ISO 8601). Inclusive.
  • sortByupdatedAt | createdAt | proposalRefqueryColumn to order by (default createdAt). No value/amount column is offered — see PROPOSAL_SORT_FIELDS for why a proposal has no single sortable value.
  • sortDirectionasc | descqueryOrder direction (default desc)
  • stillOpenbooleanqueryWhen true, excludes SENT/VIEWED proposals already past `validUntil` — ones the nightly expiry sweep has not yet moved to EXPIRED, and which the client can no longer accept. SIGNING is never excluded (it holds a grace window past the deadline). Other statuses are unaffected.

Responses

  • 200ProposalListResponse
  • 400Invalid query params
POST/v1/proposalsCreate a proposal

Create a DRAFT proposal. A sequential proposalRef is minted per tenant. When templateId is supplied the template service lines are SNAPSHOTTED onto the proposal (sortOrder preserved) and the pricing snapshot is computed from them in the same transaction — a later edit to the template never alters this proposal. A proposal with no service lines keeps null pricing columns rather than 0.00: it is unpriced, not worthless.

Opens with: API key

Parameters

  • Idempotency-KeystringheaderOptional. Repeat the same key to replay the first response instead of repeating the operation. 10-255 characters, scoped to your tenant and this exact path for 24 hours.

Request body: CreateProposalDto

  • acceptanceModeSIGNATUREHow the client accepts. `SIGNATURE` is the only supported value — acceptance is always a real e-signature ceremony, so it inherits the hash-chained audit trail and completion certificate. `CLICK` exists in the schema for historical rows and is rejected here.
  • bodyContentstringEngagement-letter / proposal body (rich HTML). REQUIRED unless `templateId` is given, in which case the template’s body is copied. Supplying it overrides the template.
  • bodyTitlestringEngagement-letter body title
  • contactIdstringRequiredContact (client) this proposal is for
  • contractStartDateobjectEngagement start date (ISO date-time). Required when SPECIFIC_DATE.
  • contractStartModeON_ACCEPTANCE | SPECIFIC_DATEWhen the engagement comes into effect. ON_ACCEPTANCE (the default) resolves to the moment the client accepts; SPECIFIC_DATE requires `contractStartDate`. Supplying a `contractStartDate` with ON_ACCEPTANCE is refused rather than ignored.
  • contractTermMonthsobjectFixed term length in whole months. Required when FIXED.
  • contractTermTypeFIXED | ONGOINGHow long the engagement runs. ONGOING (the default) has no end date and is never swept for renewal; FIXED requires `contractTermMonths`. Supplying `contractTermMonths` with ONGOING is refused rather than ignored.
  • coverLetterContentstringCover-letter rich HTML
  • currencystringISO 4217 currency code, e.g. GBP / EUR. REQUIRED unless `templateId` is given, in which case the template’s currency is used.
  • departmentIdstringWhich department this proposal belongs to. Omit and it resolves from the author: their single department, or the company-wide default when they belong to none or several. Must be one the caller may file into (GET /v1/departments/assignable) — anything else is a 400, never a silent fallback. The accepted proposal's envelope inherits this.
  • externalRefstringPartner's job/engagement id
  • externalSourcestringIntegration source, e.g. "kollabro" (null for native)
  • graceWindowDaysnumberPer-proposal abandoned-signing grace window (days). Falls back to the tenant default.
  • internalNameobjectThe company’s own label for this proposal. NEVER SHOWN TO A CLIENT — it does not reach the engagement letter, the acceptance page, the signed PDF or any email. Distinct from `bodyTitle`, which is the letter’s printed heading. Used in the proposals list, the client record and search. Send null or a blank string to clear it, after which readers fall back to `bodyTitle`.
  • paymentTermsstringPayment terms text
  • senderMessagestringOptional plain-text covering note from the sender, shown in the proposal email only. It is NOT document content: it never reaches the engagement letter, the acceptance page or the signed PDF. Frozen with the rest of the proposal at send, because only DRAFT proposals are editable. Send null or a blank string to clear it.
  • templateIdstringSource proposal template to seed from
  • termsAndConditionsstringTerms & conditions rich HTML
  • validUntilstringExplicit validity cutoff (ISO date-time)
  • vatInclusivebooleanWhether entered amounts are VAT-inclusive
  • vatRatestringProposal-level VAT rate (%), 0-100 with at most 2 decimal places

Responses

  • 201ProposalResponse
  • 400A snapshotted template line the pricing engine refuses — `code: pricing-invalid`, with `pricingCode`, `field` and a zero-based `lineIndex`
  • 404Contact or template not found
GET/v1/proposals/{id}Get a proposal

Returns the proposal with its contact summary and its frozen service lines in display order. Service lines are detail-only — the list response omits them.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200ProposalDetailResponse
  • 404Proposal not found
PATCH/v1/proposals/{id}Update a proposal

Update a DRAFT proposal. Sent proposals are frozen (supersede instead). The pricing snapshot is recomputed from the proposal current service lines — currency, vatRate and vatInclusive all change what the same lines are worth — and the recompute, the DRAFT re-check and the column write all happen in ONE transaction, under the proposal row lock, so the stored totals always match the lines they were computed from. Fields backed by a non-nullable column reject an explicit null with a 400 rather than failing in the database.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Request body: UpdateProposalDto

  • acceptanceModeSIGNATUREHow the client accepts. `SIGNATURE` is the only supported value — acceptance is always a real e-signature ceremony, so it inherits the hash-chained audit trail and completion certificate. `CLICK` exists in the schema for historical rows and is rejected here.
  • bodyContentstringEngagement-letter / proposal body (rich HTML)
  • bodyTitlestringEngagement-letter body title
  • contactIdstringContact (client) this proposal is for
  • contractStartDateobjectEngagement start date (ISO date-time). Send null when switching to ON_ACCEPTANCE.
  • contractStartModeON_ACCEPTANCE | SPECIFIC_DATEWhen the engagement comes into effect. ON_ACCEPTANCE (the default) resolves to the moment the client accepts; SPECIFIC_DATE requires `contractStartDate`. Supplying a `contractStartDate` with ON_ACCEPTANCE is refused rather than ignored.
  • contractTermMonthsobjectFixed term length in whole months. Send null when switching to ONGOING.
  • contractTermTypeFIXED | ONGOINGHow long the engagement runs. ONGOING (the default) has no end date and is never swept for renewal; FIXED requires `contractTermMonths`. Supplying `contractTermMonths` with ONGOING is refused rather than ignored.
  • coverLetterContentstringCover-letter rich HTML
  • currencystringISO 4217 currency code
  • graceWindowDaysnumberPer-proposal grace window (days)
  • internalNameobjectThe company’s own label for this proposal. NEVER SHOWN TO A CLIENT — it does not reach the engagement letter, the acceptance page, the signed PDF or any email. Distinct from `bodyTitle`, which is the letter’s printed heading. Used in the proposals list, the client record and search. Send null or a blank string to clear it, after which readers fall back to `bodyTitle`.
  • paymentTermsstringPayment terms text
  • senderMessagestringOptional plain-text covering note from the sender, shown in the proposal email only. It is NOT document content: it never reaches the engagement letter, the acceptance page or the signed PDF. Frozen with the rest of the proposal at send, because only DRAFT proposals are editable. Send null or a blank string to clear it.
  • termsAndConditionsstringTerms & conditions rich HTML
  • validUntilstringExplicit validity cutoff (ISO date-time)
  • vatInclusivebooleanWhether entered amounts are VAT-inclusive
  • vatRatestringProposal-level VAT rate (%), 0-100 with at most 2 decimal places

Responses

  • 200ProposalResponse
  • 400Proposal is no longer editable (`code: proposal-not-editable`, with `status`), an explicit null on a non-nullable field, or pricing the engine refuses (`code: pricing-invalid`, with `pricingCode`, `field` and a zero-based `lineIndex`)
  • 404Proposal not found
DELETE/v1/proposals/{id}Cancel (soft-delete) a proposal

Withdraws a non-accepted proposal to CANCELLED. If the client had started signing, the signing envelope is voided in the same transaction and its link stops working. Accepted proposals cannot be cancelled.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200ProposalActionResponse
  • 400Accepted proposal cannot be cancelled
  • 404Proposal not found
  • 409The client completed the signature while this request was in flight
GET/v1/proposals/{id}/documentFetch a proposal’s rendered engagement letter (PDF)

Returns the frozen PDF a client is shown and signs. The bytes are re-hashed on every read and refused if they do not match the sha256 recorded at send, so this endpoint either returns the exact document that was sent or returns nothing. Available once the proposal reaches SENT; a DRAFT or a render still in flight is a 404.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200The engagement-letter PDF
  • 404Proposal not found, or it has no rendered document yet
  • 500The document exists but could not be served (see logs)
GET/v1/proposals/{id}/eventsGet a proposal’s lifecycle history

The append-only `ProposalEvent` log for one proposal, newest first — created, sent, every client open (with a `firstView` flag on the one that mattered), reminders, render failures, acceptance, decline, cancellation and lost/reopened markings. This is the same log the tenant-wide activity feed reads, narrowed to one proposal and phrased for a timeline that already knows which proposal it is about. Hard-capped; `truncated` says so when the cap is hit. Not the signing hash chain — once a proposal is accepted, the evidential record of the signature itself is the envelope’s audit trail.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200ProposalEventsResponse
  • 404Proposal not found
PATCH/v1/proposals/{id}/internal-nameRename a proposal (internal label only)

Sets `internalName`, the company’s own label for a proposal. **Permitted at ANY status, unlike every other write on a proposal.** That is deliberate and does not weaken the freeze rule: `internalName` is never merged, never rendered into the PDF, never on the public acceptance page and never in an email, so it is not part of what a client agreed to. Restricting it to DRAFT defeated the field: its purpose is telling apart the proposals a company currently has OUT, and those are all SENT. Send null or a blank string to clear it, after which the proposal displays its `bodyTitle` again. Writes no `ProposalEvent`: the lifecycle log records what happened to the agreement, and relabelling a folder is not one of those things.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Request body: RenameProposalDto

  • internalNamestringRequiredThe company’s own label for this proposal. NEVER SHOWN TO A CLIENT — it does not reach the engagement letter, the acceptance page, the signed PDF or any email. Distinct from `bodyTitle`, which is the letter’s printed heading. Used in the proposals list, the client record and search. Send null or a blank string to clear it, after which readers fall back to `bodyTitle`.

Responses

  • 200ProposalResponse
  • 404Proposal not found, or outside the caller’s scope
POST/v1/proposals/{id}/lostMark a proposal as lost, with a reason

Records that the company considers this proposal lost, and why: the outcome the system could not observe (the client went quiet, went elsewhere, or said no on the phone). Does NOT change `status`, and deliberately does NOT kill the public link; `DELETE /:id` (cancel) is the act that withdraws a proposal from the client. Allowed on an already-DECLINED or already-EXPIRED proposal, which is how a reason gets attached to a loss the system detected on its own. Refused on ACCEPTED and on DRAFT.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Request body: MarkProposalLostDto

  • notestringFree-text detail. REQUIRED when reason is OTHER — an unexplained OTHER occupies a slice of the breakdown while saying nothing, which is strictly worse than no reason at all.
  • reasonPRICE | COMPETITOR | NO_LONGER_NEEDED | NO_RESPONSE | TIMING | OTHERRequiredWhy the proposal was lost. A FIXED list, deliberately not tenant-editable: a reason later renamed or deleted would rewrite the history of every proposal filed under it.

Responses

  • 200ProposalResponse
  • 400Accepted or not yet sent; or OTHER without a note
  • 404Proposal not found
DELETE/v1/proposals/{id}/lostUndo a lost marking

Clears all four lost columns together and records a REOPENED event. Idempotent: reopening a proposal that is not marked lost is a no-op, not an error.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200ProposalResponse
  • 404Proposal not found
GET/v1/proposals/{id}/previewPreview a proposal without sending it

Composes the content through the SAME merge and fee-table code the send path uses, so the words and figures are the ones a client would receive. Reports what would block a send instead of refusing, because a preview is most wanted on an unfinished draft. Not DRAFT-gated — reading back a sent proposal is useful and harmless.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200ProposalPreviewResponse
  • 404Proposal not found
GET/v1/proposals/{id}/preview-emailRender the covering email for a proposal being composed

The proposal email as the client will receive it, with every link replaced by an inert placeholder. Returns the HTML plus one data-cs-message-slot element, which is where the composer mounts its editor.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200EmailPreviewResponseDto
POST/v1/proposals/{id}/remindSend a chase reminder to the client now

Emails the client a reminder about an unaccepted proposal, immediately. Allowed on SENT and VIEWED proposals that are not marked lost and are not past their `validUntil`, and on SIGNING proposals, where the reminder goes to the half-finished signature instead. Never within 48 hours of the previous reminder, the same suppression window the nightly cadence uses, so a manual reminder and an automatic one cannot arrive a day apart. Advances the same reminder counter, which is what makes that true. Records a REMINDED event on the proposal timeline. No webhook fires: a reminder decides nothing.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 200RemindProposalResponse
  • 400The client contact has no email address
  • 404Proposal not found
  • 409The proposal cannot be chased in its current state, or a reminder went out less than 48 hours ago
  • 503The notification service could not be reached
POST/v1/proposals/{id}/retry-renderRetry a failed engagement-letter render

Re-enqueues the render for a proposal in RENDER_FAILED. The content is NOT re-resolved — it was frozen at send, and a retry must render the document that failed rather than a new one. The render key is therefore identical, so if the previous attempt did in fact store a document, the worker returns that one instead of storing a duplicate.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Responses

  • 202SendProposalResponse
  • 404Proposal not found
  • 409The proposal is not in RENDER_FAILED — `code: proposal-transition-invalid`, with `from` and `to`
POST/v1/proposals/{id}/sendSend a proposal (asynchronous)

Freezes the resolved content and the pricing snapshot, moves the proposal from DRAFT to RENDERING and queues the engagement-letter render. Returns 202 immediately. The proposal reaches SENT — and its public link becomes live — only once the render succeeds; a failed render leaves it in RENDER_FAILED, which the retry endpoint re-enqueues with the SAME frozen content. Nothing is emailed and no public token exists until SENT.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID
  • Idempotency-KeystringheaderOptional. Repeat the same key to replay the first response instead of repeating the operation. 10-255 characters, scoped to your tenant and this exact path for 24 hours.

Responses

  • 202SendProposalResponse
  • 400No engagement body, no priced services, or unresolvable merge fields
  • 404Proposal not found
  • 409The proposal is not in a state that can be sent (it has already moved on) — `code: proposal-transition-invalid`, with `from` and `to`
POST/v1/proposals/{id}/servicesAdd a service line to a proposal

Appends to the end of the list unless sortOrder is supplied. DRAFT proposals only. The line write and the pricing recompute happen in the same transaction, under the proposal row lock, so the response carries live per-bucket totals that always match the lines beside them.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID
  • Idempotency-KeystringheaderOptional. Repeat the same key to replay the first response instead of repeating the operation. 10-255 characters, scoped to your tenant and this exact path for 24 hours.

Request body: CreateProposalServiceDto

  • amountstringFee. FIXED: the fee. HOURLY: fallback when rate/hours are absent. ESTIMATE: the lower bound.
  • amountTostringUpper bound of an ESTIMATE range (must be >= amount). Display only, never summed.
  • billingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLYBilling frequency; ONE_OFF (and null) feed the one-off bucket, the rest the recurring bucket
  • descriptionobjectWhat the service covers
  • estimatedHoursstringEstimated hours (HOURLY only)
  • feeTypeFIXED | HOURLY | ESTIMATEFee type (default FIXED)
  • namestringRequiredService name, e.g. "Annual accounts"
  • ratestringHourly rate (HOURLY only)
  • sortOrdernumberDisplay position. Omit to append to the end of the list.
  • sourceServiceIduuidPROVENANCE ONLY — the catalogue service this line was created from, if it came from one. Recorded so the catalogue can report where a service is used; it does NOT link the line to the catalogue. Every value above is still the authority for what is quoted, and a later catalogue edit never reaches this proposal. Omit for a bespoke line typed straight onto the proposal.
  • vatRatestringThis line's own VAT rate as a percentage. OMIT OR SEND NULL TO INHERIT the proposal's rate, which is what the great majority of lines do. An explicit 0 is NOT the same as null: it means this service carries no VAT and keeps carrying none if the proposal rate later changes. Set it only where this service is treated differently from the rest of the document, such as a zero-rated filing fee beside a standard-rated service.

Responses

  • 201ProposalDetailResponse
  • 400Proposal is not a DRAFT (`code: proposal-not-editable`, with `status`), an invalid ESTIMATE range, or pricing the engine refuses (`code: pricing-invalid`, with `pricingCode`, `field` and a zero-based `lineIndex` naming the offending line)
  • 404Proposal not found
PATCH/v1/proposals/{id}/services/{serviceId}Update a proposal service line

Partial update on a DRAFT proposal. The line write and the pricing recompute happen in the same transaction, under the proposal row lock.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID
  • serviceIdstringpathRequiredService line ID

Request body: UpdateProposalServiceDto

  • amountstringFee
  • amountTostringUpper bound of an ESTIMATE range
  • billingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLY
  • descriptionobjectWhat the service covers
  • estimatedHoursstringEstimated hours (HOURLY only)
  • feeTypeFIXED | HOURLY | ESTIMATE
  • namestringService name
  • ratestringHourly rate (HOURLY only)
  • sortOrdernumberDisplay position
  • vatRatestringThis line's own VAT rate as a percentage. OMIT OR SEND NULL TO INHERIT the proposal's rate, which is what the great majority of lines do. An explicit 0 is NOT the same as null: it means this service carries no VAT and keeps carrying none if the proposal rate later changes. Set it only where this service is treated differently from the rest of the document, such as a zero-rated filing fee beside a standard-rated service.

Responses

  • 200ProposalDetailResponse
  • 400Proposal is not a DRAFT (`code: proposal-not-editable`, with `status`), an invalid ESTIMATE range, or pricing the engine refuses (`code: pricing-invalid`, with `pricingCode`, `field` and a zero-based `lineIndex` naming the offending line)
  • 404Proposal or service line not found
DELETE/v1/proposals/{id}/services/{serviceId}Delete a proposal service line

Hard delete on a DRAFT proposal — nothing has been quoted to a client yet, and a line on a sent proposal is unreachable through this route. The delete and the pricing recompute happen in the same transaction, under the proposal row lock.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID
  • serviceIdstringpathRequiredService line ID

Responses

  • 200ProposalDetailResponse
  • 400Proposal is not a DRAFT. `code: proposal-not-editable`, with `status`
  • 404Proposal or service line not found
PUT/v1/proposals/{id}/services/reorderReorder a proposal service lines

Rewrites sortOrder from the submitted order on a DRAFT proposal. The list must contain every service line of the proposal exactly once — partial, duplicated or foreign lists are rejected rather than partially applied. The permutation is validated under the proposal row lock, in the same transaction as the writes and the pricing recompute, so a line deleted concurrently is a 400 and never a 500.

Opens with: API key

Parameters

  • idstringpathRequiredProposal ID

Request body: ReorderProposalServicesDto

  • serviceIdsstring[]RequiredEvery one of the proposal's service line ids, exactly once, in the desired display order

Responses

  • 200ProposalDetailResponse
  • 400Proposal is not a DRAFT (`code: proposal-not-editable`), or duplicate / foreign / missing service ids
  • 404Proposal not found
GET/v1/proposals/engagementsEngagements currently in force for a client

Accepted proposals whose engagement window covers today, with their frozen service lines — the answer to "what are we contracted to deliver for this client right now". DERIVED, never stored: an engagement is an accepted proposal, and there is no separate engagement record that could disagree with the letter that created it. Excludes engagements whose start date has not yet arrived (a proposal accepted now for a term beginning next April is not active today), engagements past their end date, and anything not ACCEPTED. Proposals accepted before contract terms were recorded have no engagement window and never appear here. Ordered soonest-ending first, with ongoing engagements last. Subject to the caller’s department visibility scope.

Opens with: API key

Parameters

  • contactIdstringqueryRequiredContact (client) ID

Responses

  • 200ClientEngagementsResponse
  • 400Missing or malformed contactId
GET/v1/proposals/statsProposal status counts for the list’s tile row

Counts of drafts, proposals still with the client, accepted and expired, plus the overall total — over the SAME filters and the SAME visibility scope as `GET /proposals`. Takes every list filter except `status` and `stillOpen`, so the tiles describe the filtered set independently of which status is currently selected and each one remains clickable. The four buckets deliberately do not sum to `total`: RENDERING, RENDER_FAILED, DECLINED, SUPERSEDED and CANCELLED are counted in `total` and in no bucket.

Opens with: API key

Parameters

  • contactIdstringqueryFilter by contact
  • searchstringqueryFree-text search, identical to the list endpoint’s
  • departmentIdstringqueryOnly proposals filed to this department
  • updatedFromstringqueryLast updated at or after this instant (ISO 8601)
  • updatedTostringqueryLast updated at or before this instant (ISO 8601)
  • sentFromstringquerySent at or after this instant (ISO 8601)
  • sentTostringquerySent at or before this instant (ISO 8601)

Responses

  • 200ProposalStatsResponse
  • 400Invalid query params

Proposals (Public)

GET/v1/proposals/public/{token}Read a proposal by its public token (no authentication)

Everything the branded acceptance page needs: the company’s branding, the addressee, the frozen letter with its fee table, and whether the proposal can still be acted on. All HTML is sanitised server-side. This endpoint writes nothing — a view is recorded only by POST :token/view, so a mail-security scanner following the link cannot report the client as having read it.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe proposal’s public token (96 hex characters)

Responses

  • 200PublicProposalResponse
  • 404PublicProposalNotFoundResponse
  • 429Too many requests
POST/v1/proposals/public/{token}/acceptAccept a proposal and begin signing (no authentication)

Claims the proposal SENT|VIEWED -> SIGNING with a guarded update whose WHERE carries the expiry check, spawns a single-signer envelope over the frozen engagement letter, places the signature block on the anchor the renderer recorded, and returns the URL of the signing ceremony. Idempotent: repeat calls resume the same ceremony and never create a second envelope. Proposal-spawned envelopes are exempt from the monthly envelope quota (12), so acceptance cannot fail on a limit the client cannot see.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe proposal’s public token

Request body: AcceptProposalDto

  • authoritystringPosition held, e.g. "Director". OPTIONAL on purpose: a sole trader signing for themselves holds no office, and requiring a title would put an invented statement on a signed document.
  • emailstringRequiredWhere the signing link is bound. Defaults are pre-filled from the addressee, but a different signatory in the same organisation is legitimate and allowed.
  • namestringRequiredThe signatory as they wish to be named on the engagement letter

Responses

  • 200AcceptProposalResponse
  • 400The signatory details are incomplete or invalid
  • 404PublicProposalNotFoundResponse
  • 409Expired, declined, or withdrawn - the message says which
  • 429Too many requests
  • 503Could not start signing; the client should retry
POST/v1/proposals/public/{token}/declineDecline a proposal (no authentication)

Moves SENT|VIEWED|SIGNING -> DECLINED with a guarded update, voids any live signing envelope in the same transaction (writing its hash-chained ENVELOPE_VOIDED audit event), records the reason and fires PROPOSAL_DECLINED. The reason is optional.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe proposal public token

Request body: DeclineProposalDto

  • reasonstringFree-text reason. Optional; shown to the sender and carried on PROPOSAL_DECLINED.

Responses

  • 200DeclineProposalResponse
  • 404PublicProposalNotFoundResponse
  • 409Already accepted, already declined, or withdrawn - the message says which
  • 429Too many requests
GET/v1/proposals/public/{token}/documentFetch the engagement-letter PDF by public token (no authentication)

The exact document the client is being asked to sign. Re-hashed against the sha256 recorded at send and refused on mismatch.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe proposal’s public token

Responses

  • 200
  • 404PublicProposalNotFoundResponse
  • 429Too many requests
GET/v1/proposals/public/{token}/supporting/{supportingId}/contentRead a supporting document sent with this proposal (no authentication)

Material sent alongside the engagement letter to be READ, never signed. Proxied through the API, so no DocVault URL ever reaches the client. Defaults to rendering in the browser; pass disposition=attachment to download, which is recorded as a download. The first read writes a hash-chained ProposalEvent carrying the sha256 of the bytes served; automated fetches are recorded separately and never reach it.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe proposal’s public token
  • supportingIdstringpathRequiredSupporting document id
  • dispositionstringqueryRequired

Responses

  • 200Supporting document file stream
  • 400The item is an external link, not a document we hold
  • 404PublicProposalNotFoundResponse
  • 429Too many requests
POST/v1/proposals/public/{token}/viewRecord that a human opened the proposal

Always writes a ProposalView row and increments viewCount. Moves SENT → VIEWED only when the proposal is currently SENT, and fires PROPOSAL_VIEWED only on that one open. Requests whose User-Agent identifies an automated fetcher are counted as nothing and still answered 200 — the page is already rendered by this point; this call only decides whether it counted.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredThe proposal’s public token

Responses

  • 200PublicProposalViewResponse
  • 404PublicProposalNotFoundResponse
  • 429Too many requests

Proposals Access

GET/v1/proposals-accessList every user in the tenant with their proposals access

Includes users who do NOT have access — they are the ones an admin is here to grant. Deleted users are excluded; suspended users are not.

Opens with: API key

Responses

  • 200ProposalsAccessListResponse
PUT/v1/proposals-access/{userId}Grant or revoke one user’s proposals access

Idempotent. Revoking keeps the record with revokedAt/revokedById rather than deleting it. Returns the updated row so the screen does not need a follow-up list.

Opens with: API key

Parameters

  • userIdstringpathRequiredThe user whose access is being changed

Request body: SetProposalsAccessDto

  • hasAccessbooleanRequiredTrue grants access, false revokes it. Revoking keeps the record (with revokedAt / revokedById) rather than deleting it — who may price engagements is a governance decision, so its history has to survive.

Responses

  • 200ProposalsAccessMemberResponse
  • 404No such user in this tenant
GET/v1/proposals-access/meWhether the current caller may use proposals

Returns hasAccess=true with a null userId for an API-key caller: per-user access governs people in the UI, not the tenant’s own machine credentials.

Opens with: API key

Responses

  • 200MyProposalsAccessResponse

Recipients

GET/v1/recipientsList aggregated recipients

Get a list of unique recipients aggregated from all envelopes. Useful for autocomplete and recipient management.

Opens with: API key

Parameters

  • pagenumberquery
  • pageSizenumberquery
  • searchstringquery
  • roleSIGNER | VIEWER | CCquery

Responses

  • 200Returns list of aggregated recipients
GET/v1/recipients/{email}Get recipient envelopes

Get all envelopes associated with a specific recipient email.

Opens with: API key

Parameters

  • emailstringpathRequired
  • pagestringqueryRequired
  • pageSizestringqueryRequired

Responses

  • 200Returns recipient details with envelope history
GET/v1/recipients/suggestionsGet recipient suggestions

Get autocomplete suggestions for recipient email/name. Requires at least 2 characters.

Opens with: API key

Parameters

  • qstringqueryRequiredSearch query (min 2 chars)
  • limitnumberqueryMax results (default 10)

Responses

  • 200Returns list of recipient suggestions

Service Catalogue

GET/v1/service-catalogueThe whole catalogue, grouped into sections

Sections in order with their services, plus an `ungrouped` list for services filed under no section. Carries the single catalogue `currency` from the company profile — `isUsable` is false when that is unset, and the catalogue cannot be used until it is.

Opens with: API key

Parameters

  • includeArchivedstringqueryInclude archived sections and services (the admin archive view).

Responses

  • 200ServiceCatalogueResponse
POST/v1/service-catalogue/sectionsCreate a section (appended to the end)

Opens with: API key

Request body: CreateServiceSectionDto

  • descriptionobjectPrinted above the group in the builder
  • isPublishedbooleanWhether the group is offered right now. NOT the same as archiving — publish is reversible and routine; archive means it is gone.
  • namestringRequiredSection name, e.g. "Compliance Services"

Responses

  • 201CatalogueSectionResponse
PATCH/v1/service-catalogue/sections/{id}Update a section

`isPublished` hides the whole group from the builder without archiving it — the two are different states and are never collapsed.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateServiceSectionDto

  • descriptionobject
  • isPublishedboolean
  • namestring

Responses

  • 200CatalogueSectionResponse
DELETE/v1/service-catalogue/sections/{id}Archive a section (soft delete; idempotent)

Refuses with 409 while the section still holds active services — archiving it anyway would leave them filed under a group the UI no longer renders. Move or archive them first.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200CatalogueSectionResponse
  • 409The section still holds active services
POST/v1/service-catalogue/sections/{id}/restoreUn-archive a section

Leaves it unpublished. Exists because restoring a service refuses while its section is archived — without this, that instruction could not be carried out.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200CatalogueSectionResponse
PUT/v1/service-catalogue/sections/reorderRewrite section order

Must list every active section exactly once; a partial list is a 400.

Opens with: API key

Request body: ReorderServiceSectionsDto

  • sectionIdsstring[]RequiredEvery active section id, in the order they should appear. Must list each exactly once — a partial list is rejected rather than silently leaving the rest where they were, because "where they were" is ambiguous once anything moves.

Responses

  • 204Reordered
POST/v1/service-catalogue/servicesCreate a catalogue service (appended within its section)

Two content fields: `description` prints in the PROPOSAL, `serviceSchedule` in the ENGAGEMENT LETTER’s Schedule of Services. Writes the opening entry in the price log.

Opens with: API key

Request body: CreateCatalogueServiceDto

  • defaultAmountstringDefault fee. FIXED: the fee. HOURLY: fallback when rate/hours are absent. ESTIMATE: the lower bound. The catalogue price is a DEFAULT, never the price (§6.1.1).
  • defaultAmountTostringUpper bound of an ESTIMATE range (must be >= defaultAmount)
  • defaultBillingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLYBilling cadence. Absent is treated as ONE_OFF by the pricing engine.
  • defaultEstimatedHoursstring
  • defaultFeeTypeFIXED | HOURLY | ESTIMATEFee type (default FIXED)
  • defaultRatestring
  • defaultVatRatestringThe default VAT rate for this service, as a percentage. OMIT OR SEND NULL TO INHERIT the proposal's rate, which is what most services do. An explicit 0 is NOT the same as null: it means this service carries no VAT and keeps carrying none if the firm later changes its default rate. This is the field a practice sets once so that, for example, a zero-rated filing fee stays zero-rated on every letter it appears on.
  • descriptionobjectThe SALES description. Prints in the PROPOSAL. Not the same field as serviceSchedule.
  • isPublishedboolean
  • namestringRequiredService name, e.g. "Annual accounts"
  • sectionIdobjectSection to file it under. Omit or null for the Ungrouped bucket.
  • serviceScheduleobjectThe CONTRACTUAL scope. Prints in the ENGAGEMENT LETTER’s Schedule of Services, assembled from the services actually selected. A different document with a different audience — one field cannot be both (§6.1(b)).

Responses

  • 201CatalogueServiceResponse
  • 400An ESTIMATE whose upper bound is below its lower
GET/v1/service-catalogue/services/{id}One catalogue service, for its own page

The row plus the three things it cannot render on its own: its section’s name, the catalogue currency, and the creating/updating users as names rather than ids.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200CatalogueServiceDetailResponse
  • 404No such service in this account
PATCH/v1/service-catalogue/services/{id}Update a catalogue service

A change to any pricing field appends to the append-only price log, in the same transaction. Non-pricing edits (a rename, a description) do not.

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateCatalogueServiceDto

  • defaultAmountstringDefault fee. FIXED: the fee. HOURLY: fallback when rate/hours are absent. ESTIMATE: the lower bound. The catalogue price is a DEFAULT, never the price (§6.1.1).
  • defaultAmountTostringUpper bound of an ESTIMATE range (must be >= defaultAmount)
  • defaultBillingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLYBilling cadence. Absent is treated as ONE_OFF by the pricing engine.
  • defaultEstimatedHoursstring
  • defaultFeeTypeFIXED | HOURLY | ESTIMATEFee type (default FIXED)
  • defaultRatestring
  • defaultVatRatestringThe default VAT rate for this service, as a percentage. OMIT OR SEND NULL TO INHERIT the proposal's rate, which is what most services do. An explicit 0 is NOT the same as null: it means this service carries no VAT and keeps carrying none if the firm later changes its default rate. This is the field a practice sets once so that, for example, a zero-rated filing fee stays zero-rated on every letter it appears on.
  • descriptionobject
  • isPublishedboolean
  • namestring
  • sectionIdobject
  • serviceScheduleobject

Responses

  • 200CatalogueServiceResponse
DELETE/v1/service-catalogue/services/{id}Archive a service (soft delete; idempotent)

Never a hard delete — templates and issued proposals reference it. Also unpublishes, so restoring never puts it back in every author’s picker without a deliberate act.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200CatalogueServiceResponse
GET/v1/service-catalogue/services/{id}/price-historyAppend-only price history for one service

Most recent first, capped at 200. All six `from` fields null means the service was created at that price.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200ServicePriceHistoryResponse
POST/v1/service-catalogue/services/{id}/restoreUn-archive a service

Leaves it unpublished. Refuses with 409 if its section is archived, which would bring it back invisible.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200CatalogueServiceResponse
  • 409Its section is archived
GET/v1/service-catalogue/services/{id}/usageWhere this service is used

Distinct proposals by status, and the templates carrying a line copied from it. Counted through recorded provenance, never by matching names. NOT RETROSPECTIVE: lines written before provenance tracking have no recorded origin and are not counted — `trackedFrom` says from when the figures are complete, and a UI must show it beside them.

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200ServiceUsageResponse
  • 404No such service in this account
PUT/v1/service-catalogue/services/reorderRewrite service placement and order together

Must list every active service exactly once, each with its target section (null for Ungrouped). Placement and order travel together because one drag can change both.

Opens with: API key

Request body: ReorderCatalogueServicesDto

  • servicesServicePlacementDto[]RequiredEvery active service, in order, WITH its section. Placement and order arrive together because a drag can do both at once, and applying them as two requests would leave the catalogue briefly showing an order the user never chose.

Responses

  • 204Reordered
GET/v1/service-catalogue/starter-packsStarter packs a company can import into an empty catalogue

Wording is resolved for the tenant region (GB unless the company profile says IE); pass `region` to override. Every row carries `alreadyImported`, matched on the import link rather than the name, so a renamed service is still recognised. NO PACK CARRIES A PRICE (§6.1.6).

Opens with: API key

Parameters

  • regionGB | IEquery

Responses

  • 200StarterPackListResponse
POST/v1/service-catalogue/starter-packs/importImport the selected rows of a starter pack

One transaction. Sections are reused by name (an archived one of the same name is restored); services already imported from this pack are skipped, never updated, so a second run can only add what was left out and can never overwrite the company's own edits.

Opens with: API key

Request body: ImportStarterPackDto

  • packIdstringRequiredPack id, e.g. "accountancy".
  • regionGB | IEOverride the region the wording is taken from.
  • serviceKeysstring[]RequiredThe service keys to import. Explicit rather than "all", because the whole point of the wizard is that the company chose these.

Responses

  • 200ImportStarterPackResponse
  • 400A service key that is not in this pack
  • 404Unknown pack

Signatures

GET/v1/signaturesList saved signatures

Get all saved signatures for the current user.

Opens with: API key

Responses

  • 200SavedSignatureListResponseDtoList of saved signatures
  • 401Unauthorized
POST/v1/signaturesCreate a saved signature

Save a new signature to the user's signature library for reuse.

Opens with: API key

Request body: CreateSavedSignatureDto

  • imageDatastringRequiredSignature image data (base64 for DRAWN/UPLOADED, text for TYPED)
  • isDefaultbooleanSet as default signature
  • isInitialsbooleanIs this an initials signature
  • namestringRequiredInternal label for this saved signature (shown in pickers)
  • signerEmailstringOptional delivery email. When this signature is applied for a self-sign role, the completion notification + signed PDF are routed to this address instead of the logged-in account email.
  • signerNamestringOptional legal name to attribute to this signature on audit certificates and recipient rows when used for self-sign. Useful when an admin holds signatures for multiple staff (CEO, secretary, etc.). Falls back to the logged-in user's account name when omitted.
  • typeDRAWN | TYPED | UPLOADEDRequiredSignature type

Responses

  • 201SavedSignatureResponseDtoSignature saved successfully
  • 400Invalid data or limit reached
  • 401Unauthorized
GET/v1/signatures/{id}Get a saved signature

Get a specific saved signature by ID.

Opens with: API key

Parameters

  • idstringpathRequiredSignature ID

Responses

  • 200SavedSignatureResponseDtoSaved signature
  • 401Unauthorized
  • 404Signature not found
PUT/v1/signatures/{id}Update a saved signature

Update name or default status of a saved signature.

Opens with: API key

Parameters

  • idstringpathRequiredSignature ID

Request body: UpdateSavedSignatureDto

  • imageDatastringReplace the signature image (base64 data URI for DRAWN/UPLOADED, or generated image for TYPED). Optional — omit to leave the current image untouched while updating other fields.
  • isDefaultbooleanSet as default signature
  • namestringInternal label for this saved signature
  • signerEmailstringDelivery email for self-sign use. Pass empty string to clear; omit to leave unchanged.
  • signerNamestringLegal name to attribute to this signature on audit certificates / recipient rows when used for self-sign. Pass an empty string to clear.
  • typeDRAWN | TYPED | UPLOADEDSignature type: only required when replacing imageData

Responses

  • 200SavedSignatureResponseDtoSignature updated
  • 401Unauthorized
  • 404Signature not found
DELETE/v1/signatures/{id}Delete a saved signature

Remove a saved signature from the library.

Opens with: API key

Parameters

  • idstringpathRequiredSignature ID

Responses

  • 204Signature deleted
  • 401Unauthorized
  • 404Signature not found
POST/v1/signatures/{id}/set-defaultSet as default signature

Set this signature as the default for its type (signature or initials).

Opens with: API key

Parameters

  • idstringpathRequiredSignature ID

Responses

  • 200SavedSignatureResponseDtoSignature set as default
  • 401Unauthorized
  • 404Signature not found
GET/v1/signatures/defaultGet default signature

Get the user's default signature.

Opens with: API key

Responses

  • 200SavedSignatureResponseDtoDefault signature or null if none set
  • 401Unauthorized
GET/v1/signatures/default/initialsGet default initials

Get the user's default initials signature.

Opens with: API key

Responses

  • 200SavedSignatureResponseDtoDefault initials or null if none set
  • 401Unauthorized

Signing

GET/v1/signing/{token}Get signing session data

Public endpoint accessed via signing link. Returns all data needed to render the signing UI.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token (from email link)

Responses

  • 200SigningSessionResponseDtoReturns signing session with documents and fields
  • 404Session not found
  • 409Session expired, revoked, or already completed
  • 429Too many requests
POST/v1/signing/{token}/authenticateAuthenticate signer

For SMS or access code authentication methods. Submit the code received.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token

Request body: AuthenticateDto

  • codestringRequiredAuthentication code (SMS or access code)

Responses

  • 200AuthenticateResponseDtoAuthentication result
  • 404Session not found
  • 409Locked out due to too many failed attempts
  • 429Too many authentication attempts
POST/v1/signing/{token}/completeComplete signing session

Finalize signing after all required fields are completed. Requires consent acknowledgment.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token

Request body: CompleteSigningDto

  • consentbooleanRequiredConsent acknowledgment - signer agrees to sign electronically
  • consentVersionstringId of the consent statement the page displayed, e.g. "signing-ceremony/v1", echoed back from the session response. The wording itself is resolved server-side from that id and never taken from the request. Omit and the current statement is recorded.
  • notestringOptional note from signer

Responses

  • 200CompleteSigningResponseDtoSigning completed successfully
  • 400Not all required fields completed or missing consent
  • 404Session not found
  • 409Session already completed
  • 429Too many requests
GET/v1/signing/{token}/completed-documentDownload or view completed signed document

Returns the fully signed document after all signers have completed. Requires email verification. Defaults to a file download; pass disposition=inline to render it in the browser instead.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token
  • emailstringqueryRequired
  • dispositionstringqueryRequired

Responses

  • 200Signed document file stream
  • 400Email parameter required
  • 403Email does not match recipient
  • 404Session not found
  • 409Envelope not yet completed
GET/v1/signing/{token}/completion-statusGet completion status

Check if the envelope is fully completed and get download URL for the signed document.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token

Responses

  • 200CompletionStatusResponseDtoReturns completion status with optional download URL
  • 404Session not found
  • 429Too many requests
POST/v1/signing/{token}/declineDecline to sign

Decline signing the envelope. This will cancel the entire envelope for all recipients.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token

Request body: DeclineSigningDto

  • reasonstringRequiredReason for declining

Responses

  • 200DeclineSigningResponseDtoSigning declined
  • 404Session not found
  • 409Session already completed or declined
  • 429Too many requests
GET/v1/signing/{token}/documents/{documentId}/contentGet document content for signing

Streams the document content through the API. This proxies DocVault to avoid exposing presigned URLs to the client.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token
  • documentIdstringpathRequiredDocument ID

Responses

  • 200Document file stream
  • 404Session or document not found
  • 409Authentication required or session expired
POST/v1/signing/{token}/fields/{fieldId}Complete a field

Submit the value for a field (signature, text, checkbox, etc.)

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token
  • fieldIdstringpathRequiredField ID to complete

Request body: CompleteFieldDto

  • checkedbooleanCheckbox state (for checkbox fields)
  • selectionstringDropdown selection (for dropdown fields)
  • signatureCompleteSignatureFieldDtoSignature data (for signature/initials fields)
  • textstringText value (for text fields)
  • valueTypeTEXT | SIGNATURE | INITIALS | DATE | CHECKBOX | DROPDOWNRequiredField value type

Responses

  • 200CompleteFieldResponseDtoField completed successfully
  • 400Invalid field value — wrong payload shape for the field type, or the value breaches the author's validation rules (returned on the session field as `validation`)
  • 404Session or field not found
  • 409Field already completed
  • 429Too many requests
DELETE/v1/signing/{token}/fields/{fieldId}Clear a field value

Remove a previously entered value so the signer can re-enter or leave it blank. Only allowed before the signer has finished signing.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token
  • fieldIdstringpathRequiredField ID to clear

Responses

  • 200ClearFieldResponseDtoField value cleared successfully
  • 404Session or field not found
  • 409Session already completed
  • 429Too many requests
POST/v1/signing/{token}/resend-sms-codeResend SMS verification code

Resend the SMS verification code. Rate limited to prevent abuse.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token

Responses

  • 200SendSmsCodeResponseDtoSMS code resent (or rate limited)
  • 400Session does not use SMS authentication or no phone number
  • 404Session not found
  • 409Session expired or locked out
  • 429Too many SMS requests
POST/v1/signing/{token}/send-sms-codeSend SMS verification code

Send a new SMS verification code to the recipient's phone number. For SMS authentication only.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token

Responses

  • 200SendSmsCodeResponseDtoSMS code sent (or rate limited)
  • 400Session does not use SMS authentication or no phone number
  • 404Session not found
  • 409Session expired or locked out
  • 429Too many SMS requests
GET/v1/signing/{token}/supporting/{supportingId}/contentRead a supporting document sent with this envelope

Material sent alongside the envelope to be READ, never signed. Streamed through the API, so no DocVault URL ever reaches the client. Defaults to rendering in the browser; pass disposition=attachment to download, which is recorded as a download rather than an open. Every read writes an activity row, and the first read by this recipient also writes a hash-chained audit event carrying the sha256 of the bytes served. A request whose User-Agent identifies a mail scanner is recorded as automated and never reaches the chain. A recipient who has already signed, and a sequential signer whose turn has not come, may both read; a voided, expired or declined envelope refuses.

Opens with: No credential.

Parameters

  • tokenstringpathRequiredSigning session token
  • supportingIdstringpathRequiredSupporting document id
  • dispositionstringqueryRequired

Responses

  • 200Supporting document file stream
  • 400The item is an external link, not a document we hold
  • 404Session or supporting document not found
  • 409Authentication required, or the envelope is closed
  • 429Too many requests

Support

GET/v1/support/configWhether support is available on this environment

Returns false when HOME_GATEWAY_URL is unset (standalone dev). The web app hides the Support nav item and stops polling the unread count when false, so users are never shown a form that cannot deliver.

Opens with: API key

Responses

  • 200SupportConfigResponseDto
GET/v1/support/ticketsList the current user's support tickets

Scoped to this user’s own tickets for CecurSign only. Pass ticketNumber to resolve a /support?ticket=TKT-000042 deep link in a single call.

Opens with: API key

Parameters

  • statusopen | in_progress | awaiting_response | resolved | closedquery
  • ticketNumberstringqueryExact ticket reference, used to resolve /support?ticket=TKT-000042 deep links
  • pagenumberquery
  • limitnumberquery

Responses

  • 200SupportTicketListResponseDto
POST/v1/support/ticketsRaise a support ticket

Creates a ticket on Home’s support desk. The submitter and tenant come from the session; the browser supplies only subject, description, category, priority and page context.

Opens with: API key

Request body: CreateSupportTicketDto

  • categoryquestion | bug | feature | billing | other
  • contextSupportContextDto
  • descriptionstringRequiredWhat the user needs help with
  • formInstanceIduuidClient-generated UUID, stable for one form submission (including retries). Omit and the server generates one, which de-duplicates nothing.
  • prioritylow | medium | high | urgent
  • subjectstringRequiredTicket subject

Responses

  • 201CreateSupportTicketResponseDto
  • 400Validation failure
  • 403Support is not enabled for this account
  • 429Rate limited: retry shortly
  • 503Support is not configured or Home is unreachable
GET/v1/support/tickets/{id}Get a support ticket and its thread

Staff internal notes are stripped by Home — this only ever returns what the user is allowed to see. Does not clear the unread flag; call the read endpoint for that.

Opens with: API key

Parameters

  • idstringpathRequiredHome ticket id (UUID)

Responses

  • 200SupportTicketThreadResponseDto
  • 404No such ticket for this app and tenant
POST/v1/support/tickets/{id}/commentsReply to a support ticket

Replying reopens an awaiting-response or resolved ticket. Closed tickets reject replies with 409.

Opens with: API key

Parameters

  • idstringpathRequiredHome ticket id (UUID)

Request body: ReplyToSupportTicketDto

  • contentstringRequiredReply body

Responses

  • 201SupportReplyResponseDto
  • 409Ticket is closed: raise a new one
POST/v1/support/tickets/{id}/readClear the unread flag on a ticket

Called when the user opens the thread view. Reads are not implicit — fetching a thread never mutates state.

Opens with: API key

Parameters

  • idstringpathRequiredHome ticket id (UUID)

Responses

  • 200SupportSuccessResponseDto
GET/v1/support/tickets/statsTicket counts by status for the current user

Feeds the KPI tiles above /support. Takes no status filter on purpose — the tiles narrow the set these counts describe, so counting a filtered set would zero every tile but the pressed one.

Opens with: API key

Responses

  • 200SupportTicketStatsResponseDto
GET/v1/support/tickets/unread-countUnread reply count for the Support nav badge

Polled by the web app roughly every 60 seconds, only while the tab is visible. Cached briefly per user so multiple open tabs collapse onto one upstream call.

Opens with: API key

Responses

  • 200SupportUnreadCountResponseDto

Supporting documents

GET/v1/envelopes/{envelopeId}/supportingList the supporting documents on an envelope

Material sent alongside the envelope for the recipient to read, never to sign. Nothing here is merged into the signed PDF, named on the certificate or hashed into the audit chain.

Opens with: API key

Parameters

  • envelopeIdstringpathRequiredEnvelope id

Responses

  • 200SupportingDocumentListResponseDto
  • 404Envelope not found
POST/v1/envelopes/{envelopeId}/supportingAttach a supporting document to a draft envelope

Either a document from the firm's library or an https link to the firm's own site, never both. A library document is verified against the caller's tenant and department scope. An external link is never tracked, because the request goes to the firm's own server and never reaches us.

Opens with: API key

Parameters

  • envelopeIdstringpathRequiredEnvelope id

Request body: AttachSupportingDocumentDto

  • descriptionstringOne line of context, shown under the label
  • documentIdstringA document from this firm's library. Verified against the caller's tenant AND their department scope before it is accepted. Mutually exclusive with externalUrl.
  • externalUrlstringAn https URL the firm already publishes on its own site. Mutually exclusive with documentId. An external link is NEVER tracked: the request goes to the firm's own server and never reaches us, so nothing about it appears in the evidence route.
  • labelstringRequiredWhat the recipient sees. Not the filename: "Our privacy policy" reads better to a client than "privacy-policy-v4-FINAL.pdf".
  • ordernumberPosition in the list. Omit and it is appended to the end.

Responses

  • 201SupportingDocumentResponseDto
  • 400Both or neither source given, or a non-https link
  • 404Envelope or document not found
  • 409The envelope is no longer a draft
DELETE/v1/envelopes/{envelopeId}/supporting/{supportingId}Detach a supporting document from a draft envelope

Opens with: API key

Parameters

  • envelopeIdstringpathRequiredEnvelope id
  • supportingIdstringpathRequiredSupporting document id

Responses

  • 204Detached
  • 404Envelope or supporting document not found
  • 409The envelope is no longer a draft
GET/v1/envelopes/{envelopeId}/supporting/evidenceWho read which supporting document, and how often

Per document, per recipient. `firstOpenedAt` is the read that produced the chained audit event and the one that reaches the certificate; the counts are the lightweight activity log. Reads attributed to a mail scanner are reported separately and are never part of the counts. External links report nothing and never will.

Opens with: API key

Parameters

  • envelopeIdstringpathRequiredEnvelope id

Responses

  • 200SupportingDocumentEvidenceResponseDto
  • 404Envelope not found
PUT/v1/envelopes/{envelopeId}/supporting/orderReorder an envelope's supporting documents

Takes every id on the envelope, in the order they should appear. A partial list is refused rather than applied.

Opens with: API key

Parameters

  • envelopeIdstringpathRequiredEnvelope id

Request body: ReorderSupportingDocumentsDto

  • idsstring[]RequiredEvery supporting document id on this envelope or proposal, in the order they should appear. Ids that do not belong here are refused rather than ignored.

Responses

  • 200SupportingDocumentListResponseDto
  • 400The list is incomplete, or names something else
  • 404Envelope not found
  • 409The envelope is no longer a draft
GET/v1/proposals/{proposalId}/supportingList the supporting documents on a proposal

Material sent alongside the engagement letter for the client to read, never to sign.

Opens with: API key

Parameters

  • proposalIdstringpathRequiredProposal id

Responses

  • 200SupportingDocumentListResponseDto
  • 404Proposal not found
POST/v1/proposals/{proposalId}/supportingAttach a supporting document to a draft proposal

Either a document from the firm's library or an https link to the firm's own site, never both. A library document is verified against the caller's tenant and department scope. An external link is never tracked.

Opens with: API key

Parameters

  • proposalIdstringpathRequiredProposal id

Request body: AttachSupportingDocumentDto

  • descriptionstringOne line of context, shown under the label
  • documentIdstringA document from this firm's library. Verified against the caller's tenant AND their department scope before it is accepted. Mutually exclusive with externalUrl.
  • externalUrlstringAn https URL the firm already publishes on its own site. Mutually exclusive with documentId. An external link is NEVER tracked: the request goes to the firm's own server and never reaches us, so nothing about it appears in the evidence route.
  • labelstringRequiredWhat the recipient sees. Not the filename: "Our privacy policy" reads better to a client than "privacy-policy-v4-FINAL.pdf".
  • ordernumberPosition in the list. Omit and it is appended to the end.

Responses

  • 201SupportingDocumentResponseDto
  • 400Both or neither source given, or a non-https link
  • 404Proposal or document not found
  • 409The proposal is no longer a draft
DELETE/v1/proposals/{proposalId}/supporting/{supportingId}Detach a supporting document from a draft proposal

Opens with: API key

Parameters

  • proposalIdstringpathRequiredProposal id
  • supportingIdstringpathRequiredSupporting document id

Responses

  • 204Detached
  • 404Proposal or supporting document not found
  • 409The proposal is no longer a draft
GET/v1/proposals/{proposalId}/supporting/evidenceWhat the client read, and how often

A proposal has one public token and no recipient rows, so it reports one reader: the addressee the link was sent to. `firstOpenedAt` is the read that produced the chained ProposalEvent. Reads attributed to a mail scanner are reported separately.

Opens with: API key

Parameters

  • proposalIdstringpathRequiredProposal id

Responses

  • 200SupportingDocumentEvidenceResponseDto
  • 404Proposal not found
PUT/v1/proposals/{proposalId}/supporting/orderReorder a proposal's supporting documents

Takes every id on the proposal, in the order they should appear.

Opens with: API key

Parameters

  • proposalIdstringpathRequiredProposal id

Request body: ReorderSupportingDocumentsDto

  • idsstring[]RequiredEvery supporting document id on this envelope or proposal, in the order they should appear. Ids that do not belong here are refused rather than ignored.

Responses

  • 200SupportingDocumentListResponseDto
  • 400The list is incomplete, or names something else
  • 404Proposal not found
  • 409The proposal is no longer a draft

Team

GET/v1/team/invitationsList pending invitations

Opens with: API key

Responses

  • 200InvitationListResponseDto
POST/v1/team/invitationsInvite a new team member

Opens with: API key

Request body: CreateInvitationDto

  • departmentIdsstring[]Departments to join on acceptance. Validated against the tenant now AND again at acceptance — a department can be retired in between.
  • emailstringRequired
  • firstNamestring
  • grantProposalsbooleanGrant proposals access on acceptance. Omit to write no grant row at all, which is not the same as false: false is an explicit, audited denial.
  • lastNamestring
  • roleOWNER | ADMIN | MEMBER | VIEWERRequired
  • visibilityScopeOWN | DEPARTMENT | TENANTWhat they will be able to see, applied when they accept. Omit to leave the default (TENANT, the whole company). DEPARTMENT requires at least one departmentId.

Responses

  • 201InvitationResponseDto
DELETE/v1/team/invitations/{id}Revoke an invitation

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200
POST/v1/team/invitations/{id}/resendResend an invitation

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200InvitationDto
POST/v1/team/invitations/acceptAccept an invitation and create account

Opens with: No credential.

Request body: AcceptInvitationDto

  • firstNamestringRequired
  • lastNamestringRequired
  • passwordstringDeprecated. Passwords are set through Auth0, not here. Validated if supplied, ignored by the web client. Min 8 chars with uppercase, lowercase, number and special character.
  • tokenstringRequired

Responses

  • 201
GET/v1/team/invitations/accept/{token}Get invitation details by token

Opens with: No credential.

Parameters

  • tokenstringpathRequired

Responses

  • 200
  • 404Invitation not found, already used, or expired
GET/v1/team/membersList team members

Requires an OWNER or ADMIN session.

Opens with: API key

Responses

  • 200TeamMemberListResponseDto
  • 401Not authenticated, or insufficient role
DELETE/v1/team/members/{id}Remove team member

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200
PUT/v1/team/members/{id}/roleUpdate team member role

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateMemberRoleDto

  • roleOWNER | ADMIN | MEMBER | VIEWERRequired

Responses

  • 200TeamMemberDto

Templates

GET/v1/templatesList templates

Opens with: API key

Parameters

  • pagenumberqueryPage number (1-indexed)
  • pageSizenumberqueryPage size
  • categorystringqueryFilter by category
  • isActivebooleanqueryFilter by active status
  • searchstringquerySearch by name or description

Responses

  • 200TemplateListResponseDtoReturns list of templates
POST/v1/templatesCreate a new template

Opens with: API key

Request body: CreateTemplateDto

  • categorystringCategory for organizing templates
  • defaultExpirynumberDefault expiry in days
  • defaultMessagestringDefault message pre-populated into the envelope "message" field when this template is used
  • descriptionstringTemplate description
  • documentIdsstring[]RequiredDocument IDs to bundle into this template, in signing order
  • documentPackIdobjectA document pack to send alongside every envelope made from this template. Its items are COPIED onto the envelope at creation, so editing the pack afterwards never changes anything already sent. Null clears it.
  • isActivebooleanWhether template is active
  • namestringRequiredTemplate name
  • recipientRolesTemplateRecipientRoleDto[]RequiredRecipient roles for this template
  • signingOrderPARALLEL | SEQUENTIALSigning order for envelopes created from this template. SEQUENTIAL = roles sign in the order listed (one at a time); PARALLEL = all roles can sign at any time.

Responses

  • 201TemplateDetailResponseDtoTemplate created successfully
  • 400Invalid input
  • 404Document not found
GET/v1/templates/{id}Get template by ID

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200TemplateDetailResponseDtoReturns template details
  • 404Template not found
PUT/v1/templates/{id}Update a template

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateTemplateDto

  • categorystringCategory for organizing templates
  • defaultExpirynumberDefault expiry in days
  • defaultMessagestringDefault message pre-populated into the envelope "message" field when this template is used
  • descriptionstringTemplate description
  • documentIdsstring[]Document IDs (replaces all existing template documents when provided)
  • documentPackIdobjectA document pack to send alongside every envelope made from this template. Its items are COPIED onto the envelope at creation, so editing the pack afterwards never changes anything already sent. Null clears it.
  • isActivebooleanWhether template is active
  • namestringTemplate name
  • recipientRolesTemplateRecipientRoleDto[]Recipient roles for this template
  • signingOrderPARALLEL | SEQUENTIALSigning order for envelopes created from this template

Responses

  • 200TemplateDetailResponseDtoTemplate updated successfully
  • 400Invalid input
  • 404Template not found
DELETE/v1/templates/{id}Delete a template

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 204Template deleted successfully
  • 404Template not found
POST/v1/templates/{id}/duplicateDuplicate a template

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 201TemplateDetailResponseDtoTemplate duplicated successfully
  • 404Template not found
POST/v1/templates/{id}/envelopesCreate an envelope from a template

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 201Envelope created successfully
  • 400Invalid input or missing recipient mappings
  • 404Template not found or inactive
GET/v1/templates/categoriesGet list of template categories

Opens with: API key

Responses

  • 200string[]Returns list of category names
GET/v1/templates/most-usedTemplates ranked by how often they have been sent

Backs Home's Templates panel. Active, non-deleted templates only, most-used first, ties broken by newest. `total` counts every active template so a capped list can say what it left out. The template LIST is firm-wide (a shared library); the usage counts that rank it are department-scoped, so "most used" means most used as far as this caller can see.

Opens with: API key

Parameters

  • limitstringqueryRequired

Responses

  • 200MostUsedTemplatesResponseDtoTemplates in descending use

Tenants

GET/v1/tenants/brandingGet current branding settings

Opens with: Signed-in session or API key

Responses

  • 200BrandingResponseDtoReturns branding settings
  • 401Not authenticated
  • 404Tenant not found
PUT/v1/tenants/brandingUpdate branding settings (primary colour, company name)

Requires an OWNER or ADMIN session.

Opens with: Signed-in session or API key

Request body: UpdateBrandingDto

  • companyNamestringCompany name displayed in signing pages
  • primaryColorstringPrimary brand colour in hex format (#RRGGBB)

Responses

  • 200BrandingResponseDtoBranding updated
  • 400Invalid input
  • 401Not authenticated
  • 403Plan does not include custom branding
  • 404Tenant not found
GET/v1/tenants/currentGet current tenant

Opens with: Signed-in session or API key

Responses

  • 200TenantResponseDtoReturns current tenant
  • 401Not authenticated
  • 404Tenant not found
PATCH/v1/tenants/currentUpdate the current tenant

Requires an OWNER or ADMIN session. Not available to API keys, because RolesGuard needs a person and these fields are the company's public identity. Partial update of `name`, `primaryColor` and `senderDisplayName`. The `settings` JSONB column is NOT writable through this route and a request carrying it is rejected with 400: it holds plan entitlements, quotas and suspension state, which Cecur Home owns and writes through the provisioning API.

Opens with: Signed-in session or API key

Request body: UpdateTenantDto

  • namestringTenant/Organization name
  • primaryColorstringPrimary brand color (hex format)
  • senderDisplayNameobjectDefault "from" name used on outbound envelope emails. Set to your company name to send under the organisation rather than the individual team member. Pass an empty string to clear.

Responses

  • 200TenantResponseDtoTenant updated
  • 400Invalid input, or a non-writable property was sent
  • 401Not authenticated, or insufficient role
  • 403Sent `primaryColor` on a plan without custom branding
  • 404Tenant not found
GET/v1/tenants/current/company-profileGet the tenant company profile

Returns the company identity used by proposals and engagement letters: the source of the {{companyName}}, {{companyAddress}}, {{companyEmail}} and {{companyVatNumber}} merge tokens, plus the per-tenant default currency, VAT rate and proposal grace window. `isCompleteForProposals` is true only when firmName, companyAddress and companyEmail are all present and non-blank.

Opens with: Signed-in session or API key

Responses

  • 200CompanyProfileResponseDtoReturns the company profile
  • 401Not authenticated
  • 404Tenant not found
PATCH/v1/tenants/current/company-profileUpdate the tenant company profile

Requires an OWNER or ADMIN session. Not available to API keys, because these fields are the company's legal identity on a binding engagement letter. Partial update. Omitted keys are left unchanged; an explicit null (or a blank string) clears a nullable field. `firmName` is not editable here - it mirrors Tenant.name, which is set via PATCH /v1/tenants/current or PUT /v1/tenants/branding. Region-derived defaults: when `region` is set to GB or IE AND the tenant has no default currency / VAT rate yet AND the request does not supply them, they are seeded (GB -> GBP / 20.00, IE -> EUR / 23.00). A value the tenant already chose is never overwritten, a value supplied in the same request always wins, and a region outside the map seeds nothing.

Opens with: Signed-in session or API key

Request body: UpdateCompanyProfileDto

  • companyAddressobjectRegistered company address, source of the {{companyAddress}} merge token. Send null (or an empty string) to clear it; omit the key to leave it unchanged.
  • companyEmailobjectPublic company email address, source of the {{companyEmail}} merge token. Send null (or an empty string) to clear it; omit the key to leave it unchanged.
  • companyRegistrationNumberobjectCompanies registration number (CRO / Companies House). Send null (or an empty string) to clear it; omit the key to leave it unchanged.
  • defaultCurrencyobjectISO 4217 currency code used as the default for new proposals. Send null to clear it; omit the key to leave it unchanged.
  • defaultVatRatestringDefault VAT rate (percent) for new proposals, 0-100 with at most 2 decimal places. Accepts a numeric string ("23.00", preferred) or a number. Send null to clear it; omit the key to leave it unchanged.
  • financialYearStartDayobjectThe day of that month the financial year starts (1-31). Stored separately because a UK sole trader's year starts 6 April, not 1 April — month alone would mis-window a large part of the UK market by five days.
  • financialYearStartMonthobjectThe month this company's financial year starts (1-12). Null clears it, which HIDES the "This financial year" analytics preset rather than falling back to January, because a confidently-labelled year that is not the company's own year is worse than no preset.
  • proposalAutoRemindersEnabledbooleanWhether the nightly sweep chases clients who have not answered a proposal (day 3, 7 and 14 after sending, plus a final nudge two days before it expires). Defaults to true. Not nullable - omit the key to leave it unchanged. Turning it off does NOT disable the manual Remind button, which is an explicit act by someone looking at the proposal.
  • proposalGraceWindowDaysnumberDefault grace window in days for abandoned proposal signing ceremonies. Not nullable - omit the key to leave it unchanged.
  • regionobjectISO 3166-1 alpha-2 country code for the company. Drives currency/date formatting (en-GB / en-IE) and can seed the currency and VAT defaults - see the PATCH endpoint description. Send null to clear it; omit the key to leave it unchanged.
  • vatNumberobjectVAT registration number, source of the {{companyVatNumber}} merge token. Send null (or an empty string) to clear it; omit the key to leave it unchanged.

Responses

  • 200CompanyProfileResponseDtoCompany profile updated
  • 400Invalid input
  • 401Not authenticated, or insufficient role
  • 404Tenant not found
GET/v1/tenants/current/statsGet current tenant statistics

Opens with: Signed-in session or API key

Responses

  • 200TenantStatsDtoReturns tenant statistics
  • 401Not authenticated

Users

GET/v1/users/meGet current user profile

Opens with: Signed-in session or API key

Responses

  • 200UserProfileResponseDtoReturns current user
  • 401Not authenticated
PATCH/v1/users/meUpdate current user profile

Opens with: Signed-in session or API key

Request body: UpdateProfileDto

  • avatarUrlstringAvatar URL
  • firstNamestringFirst name
  • lastNamestringLast name
  • phonestringPhone number
  • senderDisplayNameOverrideobjectPer-user override for the "from" name on outbound envelope emails. When set, takes precedence over the tenant's senderDisplayName. Pass an empty string to clear and revert to the tenant default.

Responses

  • 200UserProfileResponseDtoProfile updated
  • 400Invalid input
  • 401Not authenticated
PATCH/v1/users/me/passwordChange password

Opens with: Signed-in session or API key

Request body: ChangePasswordDto

  • currentPasswordstringRequiredCurrent password
  • newPasswordstringRequiredNew password (min 8 chars, must include uppercase, number, and special character)

Responses

  • 200MessageResponseDtoPassword changed
  • 400Invalid input or password requirements not met
  • 401Not authenticated or current password incorrect

Verification

GET/v1/verification/{envelopeId}Verify an envelope

Public endpoint to verify the authenticity of a signed envelope

Opens with: No credential.

Parameters

  • envelopeIduuidpathRequiredThe envelope ID to verify

Responses

  • 200objectVerification result
  • 404Envelope not found

Version

GET/versionWhat code is running here

Identity, not health. Requires the X-Cecur-Version-Key header. Answers 503 if the key is not configured on this host, and 401 — never 404 — if the presented key is wrong or absent, so that "misconfigured" stays distinguishable from "never implemented".

Opens with: No credential.

Parameters

  • x-cecur-version-keystringheaderRequiredShared secret from CECUR_VERSION_KEY. Compared in constant time.

Responses

  • 200VersionResponseDto
  • 401The version key was missing or wrong.
  • 503CECUR_VERSION_KEY is not configured on this host.

Webhooks

GET/v1/webhooksList webhooks

Get all webhook endpoints for your account

Opens with: API key

Responses

  • 200WebhookListResponseDtoList of webhooks
POST/v1/webhooksCreate a webhook

Register a new webhook endpoint to receive event notifications

Opens with: API key

Request body: CreateWebhookDto

  • eventsENVELOPE_SENT | ENVELOPE_VIEWED | ENVELOPE_COMPLETED | ENVELOPE_DECLINED | ENVELOPE_VOIDED | ENVELOPE_EXPIRED | RECIPIENT_COMPLETED | PROPOSAL_SENT | PROPOSAL_VIEWED | PROPOSAL_ACCEPTED | PROPOSAL_DECLINED | PROPOSAL_EXPIRED | PROPOSAL_TERM_ENDING | BOOKING_CREATED | BOOKING_CANCELLED | BOOKING_RESCHEDULED[]RequiredArray of event types to subscribe to
  • urlstringRequiredThe https URL to receive webhook events. Must not resolve to a private, loopback or link-local address.

Responses

  • 201WebhookResponseDtoWebhook created successfully
  • 400Invalid request or duplicate URL
GET/v1/webhooks/{id}Get a webhook

Get details of a specific webhook endpoint

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200WebhookResponseDtoWebhook details
  • 404Webhook not found
PUT/v1/webhooks/{id}Update a webhook

Update webhook URL, events, or active status

Opens with: API key

Parameters

  • idstringpathRequired

Request body: UpdateWebhookDto

  • eventsENVELOPE_SENT | ENVELOPE_VIEWED | ENVELOPE_COMPLETED | ENVELOPE_DECLINED | ENVELOPE_VOIDED | ENVELOPE_EXPIRED | RECIPIENT_COMPLETED | PROPOSAL_SENT | PROPOSAL_VIEWED | PROPOSAL_ACCEPTED | PROPOSAL_DECLINED | PROPOSAL_EXPIRED | PROPOSAL_TERM_ENDING | BOOKING_CREATED | BOOKING_CANCELLED | BOOKING_RESCHEDULED[]Array of event types to subscribe to
  • isActivebooleanWhether the webhook is active
  • urlstringThe https URL to receive webhook events. Must not resolve to a private, loopback or link-local address.

Responses

  • 200WebhookResponseDtoWebhook updated
  • 404Webhook not found
DELETE/v1/webhooks/{id}Delete a webhook

Remove a webhook endpoint

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200Webhook deleted
  • 404Webhook not found
GET/v1/webhooks/{id}/deliveriesGet webhook deliveries

View delivery history for a webhook

Opens with: API key

Parameters

  • idstringpathRequired
  • limitnumberqueryMaximum number of deliveries to return (default: 50)

Responses

  • 200WebhookDeliveryListResponseDtoList of deliveries
  • 404Webhook not found
POST/v1/webhooks/{id}/deliveries/{deliveryId}/retryRetry a failed delivery

Re-queue a failed or dead letter webhook delivery for retry

Opens with: API key

Parameters

  • idstringpathRequired
  • deliveryIdstringpathRequired

Responses

  • 200Delivery re-queued
  • 400Cannot retry this delivery
  • 404Webhook or delivery not found
POST/v1/webhooks/{id}/regenerate-secretRegenerate webhook secret

Generate a new signing secret for webhook verification

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200WebhookResponseDtoSecret regenerated
  • 404Webhook not found
POST/v1/webhooks/{id}/testTest a webhook

Send a test event to verify webhook is working

Opens with: API key

Parameters

  • idstringpathRequired

Responses

  • 200TestWebhookResponseDtoTest result
  • 404Webhook not found
GET/v1/webhooks/dead-letterGet dead letter deliveries

View all permanently failed webhook deliveries across all webhooks

Opens with: API key

Parameters

  • limitnumberqueryMaximum number of deliveries to return (default: 50)

Responses

  • 200WebhookDeliveryListResponseDtoList of dead letter deliveries