- Developers
- Reference
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.limitnumberquerysubjectsigning | 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 data403Feature 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 data403Feature 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 data403Feature not available on current plan
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 summary403Feature 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 data403Insufficient 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 distribution403Feature not available on current plan
GET/v1/analytics/trendsGet envelope trends over time
Returns daily, weekly, or monthly envelope counts (sent and completed) over the specified period.
Opens with: API key
Parameters
startDatestringqueryStart date (ISO 8601). Defaults to 30 days ago.endDatestringqueryEnd date (ISO 8601). Defaults to now.granularityday | week | monthqueryGranularity: day, week, or month. Defaults to day.
Responses
200TrendsResponseDtoTrends data403Feature 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 keys401Not authenticated403An 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 keyrequireActingUserbooleanRefuse 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 successfully400Invalid input401Not authenticated403An 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 details401Not authenticated403An API key (a key cannot manage keys), or a role below ADMIN404API 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 revoked401Not authenticated403An API key (a key cannot manage keys), or a role below ADMIN404API 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 exists429Too many password reset requests
POST/v1/auth/loginLogin with email and password
Opens with: No credential.
Request body: LoginDto
emailstringRequiredUser email addresspasswordstringRequiredUser password
Responses
200LoginResponseDtoLogin successful401Invalid credentials429Too 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 applicable429Too 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 successful400Invalid or expired token429Too many password reset attempts
POST/v1/auth/verify-emailVerify email with token
Opens with: No credential.
Request body: VerifyEmailDto
tokenstringRequiredEmail verification token
Responses
200VerifyEmailResponseDtoEmail verified400Invalid 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 cataloguequantitynumberHow many packs. Defaults to 1.
Responses
200AddOnCheckoutSessionResponseDtoCheckout session created; redirect the browser to `url`403The booster is not sold on this account's plan503Cecur 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 cadenceconfirmedImmediateChargebooleanSet 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 consideredplanINDIVIDUAL | PROFESSIONAL | BUSINESS | ENTERPRISERequiredPlan being considered
Responses
200PlanChangePreviewDtoWhat the change would do, and what it would cost including VAT503Cecur 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)
GET/v1/book/public/branding/{tenantId}/logoPublic tenant logo (no authentication required)
Opens with: No credential.
Parameters
tenantIduuidpathRequired
Responses
200Logo bytes with image content-type404Tenant has no logo
GET/v1/branding/{tenantId}/logoPublic tenant logo (no authentication required)
Opens with: No credential.
Parameters
tenantIduuidpathRequired
Responses
200Logo bytes with image content-type404Tenant has no logo
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 batchrecipientsBulkSendRecipientInput[]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 queued400Invalid request404Template 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 details404Bulk 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 cancelled400Cannot cancel404Bulk 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
200PublicBookingResponse404PublicNotFoundResponse429Too 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 document404429Too 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
200Cancelled404PublicNotFoundResponse409This booking is in a state that cannot be cancelled429Too 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
reasonstringstartstringRequiredThe new slot start, ISO-8601.timeZonestringThe booker’s time zone, if it has changed since they first booked.
Responses
201PublicBookingCreatedResponse404PublicNotFoundResponse409That time is not on offer, or was taken while this request was in flight429Too 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 tokenfromstringqueryRequiredISO-8601. Start of the window to list slots in.tostringqueryRequiredISO-8601. End of the window. At most 62 days after `from`.
Responses
200PublicSlotsResponse404PublicNotFoundResponse429Too 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
200PublicHandleEventTypeResponse404PublicNotFoundResponse429Too 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
200PublicEventTypeResponse404PublicNotFoundResponse429Too 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
201PublicBookingCreatedResponse404PublicNotFoundResponse409That time is not on offer, or somebody else took it while this request was in flight429Too 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 tokenfromstringqueryRequiredISO-8601. Start of the window to list slots in.tostringqueryRequiredISO-8601. End of the window. At most 62 days after `from`.
Responses
200PublicSlotsResponse400Inverted window, or one longer than 62 days404PublicNotFoundResponse429Too 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
200PublicHostPageResponse404PublicNotFoundResponse429Too 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
201BookingHandleResponse400The 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
200HandleAvailabilityResponse429Too 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_SHOWqueryuserIdstringqueryFilter 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.limitnumberqueryoffsetnumberquery
Responses
200BookingListResponse
GET/v1/scheduling/bookings/{id}Get one booking
Opens with: API key
Parameters
iduuidpathRequired
Responses
200BookingResponse404Booking 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
200BookingActionResponse404No such booking, or not one this caller may cancel409This 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
reasonstringstartstringRequiredThe 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
201BookingActionResponse404No such booking, or not one this caller may reschedule409The 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
200GoogleCalendarListResponse404No 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
200StartGoogleConnectResponse503Google 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
200CalendarConnectionResponse404No 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
hostUserIdstringpathRequiredgranteeUserIdstringpathRequired
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
bufferAfterMinutesnumberbufferBeforeMinutesnumberPadding 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.descriptionstringdurationMinutesnumberRequiredisActivebooleanminimumNoticeMinutesnumberHow 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.rollingWindowDaysnumberscheduleIdobjectThe 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".titlestringRequireduserIdstringThe host. Required when the caller is an API key, which has no user of its own; defaults to the session user otherwise.
Responses
201EventTypeResponse403Only an owner or admin may name another host409This 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
200EventTypeResponse404No 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
bufferAfterMinutesnumberbufferBeforeMinutesnumberdescriptionobjectdurationMinutesnumberisActivebooleanminimumNoticeMinutesnumberquestionsobject[]remindGuestsbooleanWhether invited guests are reminded the day before. The reminder only; guests always receive the confirmation, the cancellation and the reschedule.rollingWindowDaysnumberscheduleIdobjectslotIntervalMinutesobjectslugstringtitlestring
Responses
200EventTypeResponse404No 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
204Deleted404No such event type, or not one this caller may delete409Bookings 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
iduuidpathRequiredfromstringqueryRequiredISO-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
200HostSlotListResponse400The window is inverted, longer than 62 days, or the event type resolves to no schedule404Event 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.namestringRequiredrulesAvailabilityRuleDto[]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
201ScheduleResponse400Unknown time zone, or a rule with no valid reading403Only 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
200ScheduleResponse404No 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
isDefaultbooleannamestringtimeZonestringChanging 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
200ScheduleResponse404No 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
204Deleted404No such schedule, or not one this caller may delete409Event 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
200ScheduleResponse400A rule with no valid reading, or two overrides on one date404No 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
200ScheduleResponse400Authenticated with an API key, which has no user409The 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 forelapsedMsnumberClient-measured time on the form, in millisecondsemailstringRequiredWhere a reply should goenquiryTypeGENERAL | SALES | SECURITY | EXISTING_CUSTOMERWhat the enquiry is about. Steers the subject line; grants nothing.messagestringRequiredThe enquiry itselfnamestringRequiredThe name the enquirer gavewebsitestringAnti-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 failure429Rate limited: try again shortly503The 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
pagenumberquerypageSizenumberquerysearchstringquerycategorystringqueryfavoritesOnlybooleanqueryincludeArchivedbooleanqueryInclude 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 contactcompanystringCompany nameemailstringRequiredContact email addressisFavoritebooleanMark as favoritenamestringContact namenotesstringNotes about the contactphonestringContact phone number
Responses
201Contact created409Contact 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 details404Contact 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 contactcompanystringCompany nameemailstringContact email addressisFavoritebooleanMark as favoritenamestringContact namenotesstringNotes about the contactphonestringContact phone number
Responses
200Contact updated404Contact not found409Contact 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
200DeleteContactResponse404Contact 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 IDpagenumberquerypageSizenumberquery
Responses
200Returns the client’s documents, newest first404Contact not found
GET/v1/contacts/{id}/envelopesGet contact envelope history
Get all envelopes associated with a contact
Opens with: API key
Parameters
idstringpathRequiredContact IDpagenumberquerypageSizenumberquery
Responses
200Returns envelope history404Contact 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 toggled404Contact 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
201ContactResponse404Contact 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/searchSearch contacts
Search contacts and recent recipients for autocomplete. Returns both saved contacts and recipients from envelope history.
Opens with: API key
Parameters
qstringqueryRequiredSearch query (min 2 chars)limitnumberqueryMax results (default 10)
Responses
200Returns matching contacts and 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
searchstringquerycategorystringqueryfavoritesOnlybooleanqueryincludeArchivedbooleanquery
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
200DashboardLayoutResponseDto400Unknown 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
descriptionstringnamestringRequired
Responses
201DepartmentResponse409A 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
descriptionstringisActivebooleanSet false to retire a department without deleting its history.namestring
Responses
200DepartmentResponse400The default department cannot be deactivated404Department 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
200DepartmentResponse400The default department cannot be deactivated404Department 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
200DepartmentRosterMemberResponse400Empty department list, or a department not in this company404No 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 companysortOrdernumberDisplay order, ascending
Responses
201DocumentCategoryResponseDto409A 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 colournamestringNew namesortOrdernumberNew display order
Responses
200DocumentCategoryResponseDto404Category not found409A 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
204Deleted404Category 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
200PublicCopyResponseDto404The link is not valid410The 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 PDF404The link is not valid410The 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
201DocumentPackResponseDto400An item gave both or neither source, or a non-https link404One of the documents was not found409A 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
200DocumentPackResponseDto404Document 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 packsitemsDocumentPackItemInputDto[]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
200DocumentPackResponseDto400An item gave both or neither source, or a non-https link404Document pack, or one of the documents, not found409A 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
204Deleted404Document 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 labeldocumentIdstringA 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
201DocumentPackResponseDto400Both or neither source given, or a non-https link404Document 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 iditemIdstringpathRequiredPack item id
Responses
200DocumentPackResponseDto404Document 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
200DocumentPackResponseDto400The list is incomplete, or names something else404Document pack not found
Documents
GET/v1/documentsList all documents
Opens with: API key
Parameters
pagenumberqueryPage number (1-indexed)pageSizenumberqueryPage sizestatusPROCESSING | READY | ERROR | DELETEDqueryFilter by statussearchstringquerySearch by namesortrecent | 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 documents401Unauthorized
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 successfully400Invalid file type or size401Unauthorized
GET/v1/documents/{id}Get document by ID
Opens with: API key
Parameters
idstringpathRequiredDocument ID
Responses
200DocumentResponseDtoDocument details404Document 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 document400Empty name404Document not found
DELETE/v1/documents/{id}Delete a document
Opens with: API key
Parameters
idstringpathRequiredDocument ID
Responses
204Document deleted403Document is in use404Document 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 viewing404Document not found
GET/v1/documents/{id}/detailsGet document with pages and fields
Opens with: API key
Parameters
idstringpathRequiredDocument ID
Responses
200DocumentWithPagesDtoDocument with pages and fields404Document not found
GET/v1/documents/{id}/downloadDownload document content
Opens with: API key
Parameters
idstringpathRequiredDocument ID
Responses
200Document file stream404Document 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 fields400Missing or conflicting scope404Document 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 labellinkKeystringLink 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 textrecipientRolestringRecipient role this field is assigned torequiredbooleanIs field requiredtypeSIGNATURE | INITIALS | DATE_SIGNED | TEXT | CHECKBOX | DROPDOWN | ATTACHMENT | RADIO | DATERequiredField typevalidationobjectValidation rules (JSON)widthnumberRequiredWidth (0-1 normalized)xnumberRequiredX position (0-1 normalized)ynumberRequiredY position (0-1 normalized)
Responses
201FieldResponseDtoField created400Invalid field data or missing/conflicting scope404Document not found
PUT/v1/documents/{id}/fields/{fieldId}Update a field within a template or envelope scope
Opens with: API key
Parameters
idstringpathRequiredDocument IDfieldIdstringpathRequiredField ID
Request body: UpdateFieldDto
groupKeystringGroup key for RADIO fieldsheightnumberHeight (0-1 normalized)labelstringField labelmultilinebooleanFor TEXT fields: allow multiple lines (textarea + word-wrapped PDF output).optionLabelstringOption label for RADIO fieldsplaceholderstringPlaceholder textrecipientRolestringRecipient rolerequiredbooleanIs field requiredwidthnumberWidth (0-1 normalized)xnumberX position (0-1 normalized)ynumberY position (0-1 normalized)
Responses
200FieldResponseDtoField updated404Document 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 IDfieldIdstringpathRequiredField ID
Responses
204Field deleted404Document 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 created400Invalid field data or missing/conflicting scope404Document 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 placementsfieldsCreateFieldDto[]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 replacement400Invalid scope or field data404Document/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 groups400Missing or conflicting scope404Document not found
GET/v1/documents/{id}/pages/{pageNumber}Get page thumbnail URL
Opens with: API key
Parameters
idstringpathRequiredDocument IDpageNumbernumberpathRequiredPage number (1-indexed)
Responses
200objectPage thumbnail URL400Invalid page number404Document 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 first401Unauthorized
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 insteadpageSizenumberqueryDeprecatedPage size - DEPRECATED: Use limit insteadstatusDRAFT | 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 emailfolderIdstringqueryFilter 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 envelopeexpiresAtdate-timeExpiration date for the envelopefolderIdstringFolder ID to assign the envelope toidempotencyKeystringIdempotency key for this request. Can also be provided via the Idempotency-Key header.messagestringMessage to recipientsmetadataobjectCustom metadatarecipientsCreateRecipientDto[]RequiredRecipients of the envelopesigningOrderPARALLEL | SEQUENTIALSigning order typesubjectstringRequiredEnvelope subject (email subject line)templateIdstringTemplate ID if creating from a template
Responses
201EnvelopeDetailResponseDtoEnvelope created successfully400Invalid input, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header404Document(s) not found
GET/v1/envelopes/{id}Get envelope by ID
Opens with: API key
Parameters
idstringpathRequired
Responses
200EnvelopeDetailResponseDtoReturns envelope details404Envelope 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 typesubjectstringEnvelope subject (email subject line)
Responses
200EnvelopeDetailResponseDtoDraft updated400Invalid input, or a document is not ready404Envelope, document or folder not found409The 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 successfully404Envelope not found409Only 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 verification404Envelope 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 results404Envelope 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 result404Envelope not found
GET/v1/envelopes/{id}/certificateGet certificate of completion
Opens with: API key
Parameters
idstringpathRequired
Responses
200Returns PDF certificate404Envelope 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
200EnvelopeCopyListResponseDto404Envelope 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
201EnvelopeCopyResponseDto400Invalid address, or this firm has reached its hourly limit for copies403An API key cannot send a copy404Envelope not found409The 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
idstringpathRequiredcopyIdstringpathRequired
Responses
200The link is withdrawn404Envelope or copy link not found
GET/v1/envelopes/{id}/documents/{documentId}/downloadDownload envelope document content
Opens with: API key
Parameters
idstringpathRequireddocumentIdstringpathRequired
Responses
200Document file stream404Envelope 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 stream404Envelope 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 successfully400Invalid input, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header404Envelope not found
GET/v1/envelopes/{id}/evidenceGet evidence package (ZIP)
Opens with: API key
Parameters
idstringpathRequired
Responses
200Returns ZIP file404Envelope 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 updated404Envelope 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
200QuickSignResponseDtoSigned400Cannot 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
idstringpathRequiredrecipientIdstringpathRequired
Responses
200Returns embeddable signing URL403Feature not available on current plan404Envelope or recipient not found409Recipient 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
idstringpathRequiredrecipientIdstringpathRequired
Responses
200Recipient marked as notified404Envelope or recipient not found
GET/v1/envelopes/{id}/recipients/{recipientId}/signing-linkGet signing link for a recipient
Get the signing URL for a specific recipient. Useful for LINK_ONLY recipients where you need to manually share the link. Requires a WRITE-scoped API key: the link confers the ability to sign.
Opens with: API key
Parameters
idstringpathRequiredrecipientIdstringpathRequired
Responses
200RecipientSigningLinkDtoReturns signing link details404Envelope, recipient, or signing session not found
POST/v1/envelopes/{id}/remindSend reminder to pending recipients
Opens with: API key
Parameters
idstringpathRequired
Responses
200Reminders sent404Envelope not found409Cannot 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 successfully400Invalid input, or an API-key request that does not name the person it is acting for (`ACTING_USER_REQUIRED`) — see the X-Acting-User header404Envelope not found409Envelope 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 groupingdescriptionstringTemplate descriptionnamestringRequiredTemplate name
Responses
201TemplateDetailResponseDtoTemplate created400The envelope is a proposal acceptance, has no documents, or a document is gone404Envelope 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 successfully400Invalid envelope state or missing fields404Envelope not found409Envelope 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 set404Envelope 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 successfully404Envelope not found409Cannot 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 movefolderIdobjectRequiredFolder ID to move envelopes to (null to remove from folder)
Responses
200Envelopes moved404Folder 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.recipientNamestringsubjectstringRequiredThe 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 envelopefieldsSelfSignFieldDto[]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 metadatasubjectstringRequiredDocument 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 header404Document(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’sfolderIdstringqueryFilter by folder ID. Use "null" for envelopes not in any folder.contactEmailstringqueryFilter to one client by recipient email, identical to the list endpoint'ssourceDIRECT | PROPOSAL_ACCEPTANCE | SELF_SIGNqueryFilter by provenance, identical to the list endpoint'sincludeAllbooleanqueryInclude every source, hidden ones too. Ignored when `source` is given. Omit for the default, which is `DIRECT` only.
Responses
200EnvelopeStatsResponseDto400Invalid query params
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
200LinkFieldsResponseDto400Fewer 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
200DetectSuggestionsResponseDto404Document not found500Detection 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
200AcceptSuggestionResponseDto400Already 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 nameparentIdstringParent folder ID for nesting
Responses
201Folder created404Parent folder not found409Folder 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 folder404Folder 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 iconnamestringFolder nameparentIdobjectParent folder ID (null to move to root)
Responses
200Folder updated404Folder not found409Folder 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 deleted404Folder 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 moved400Invalid 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 path404Folder 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 read404Notification 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 preferences401Unauthorized
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 preferences401Unauthorized
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
200SetupChecklistResponseDtoChecklist401Unauthorized
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 list401Unauthorized
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 cleared401Unauthorized
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 state401Unauthorized
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 state401Unauthorized
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
200ProposalTemplateListResponse400Invalid 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 textdefaultValidityDaysnumberDefault 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 descriptiondocumentPackIdobjectA 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 nameservicesCreateProposalTemplateServiceDto[]Default service/fee lines, created in array order when sortOrder is omittedsigningTemplateIdobjectSigning (envelope) template used when acceptance spawns a signing envelope. Null ⇒ auto-build a single-signer signature block.termsAndConditionsobjectTerms & conditions rich HTML
Responses
201ProposalTemplateResponse400No currency on the request or the company profile, or an invalid ESTIMATE range404Tenant 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
200ProposalTemplateResponse404Proposal 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 titlecoverLetterContentobjectCover-letter rich HTMLcurrencystringISO 4217 currency codedefaultAcceptanceModeSIGNATUREHow 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 textdefaultValidityDaysnumberDefault validity window in daysdefaultVatInclusivebooleanWhether entered amounts are VAT-inclusivedefaultVatRatestringDefault VAT rate (%); null clears it ("no VAT")descriptionobjectInternal descriptiondocumentPackIdobjectA 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 namesigningTemplateIdobjectSigning (envelope) template id; null clears ittermsAndConditionsobjectTerms & conditions rich HTML
Responses
200ProposalTemplateResponse400An archived template cannot be made the default404Proposal 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
200ProposalTemplateActionResponse404Proposal 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
201ProposalTemplateResponse404Proposal 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 bucketdefaultEstimatedHoursstringEstimated 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 coversnamestringRequiredService 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
201ProposalTemplateServiceResponse400Invalid ESTIMATE range404Proposal 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 IDserviceIdstringpathRequiredService line ID
Request body: UpdateProposalTemplateServiceDto
defaultAmountstringDefault feedefaultAmountTostringUpper bound of an ESTIMATE rangedefaultBillingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLYdefaultEstimatedHoursstringEstimated hours (HOURLY only)defaultFeeTypeFIXED | HOURLY | ESTIMATEdefaultRatestringHourly 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 coversnamestringService namesortOrdernumberDisplay position
Responses
200ProposalTemplateServiceResponse400Invalid ESTIMATE range404Service 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 IDserviceIdstringpathRequiredService line ID
Responses
200ProposalTemplateActionResponse404Service 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
200ProposalTemplateResponse400Duplicate, foreign, or missing service ids404Proposal 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 contactdepartmentIdstringqueryOnly 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
200ProposalListResponse400Invalid 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 titlecontactIdstringRequiredContact (client) this proposal is forcontractStartDateobjectEngagement 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 HTMLcurrencystringISO 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 idexternalSourcestringIntegration 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 textsenderMessagestringOptional 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 fromtermsAndConditionsstringTerms & conditions rich HTMLvalidUntilstringExplicit validity cutoff (ISO date-time)vatInclusivebooleanWhether entered amounts are VAT-inclusivevatRatestringProposal-level VAT rate (%), 0-100 with at most 2 decimal places
Responses
201ProposalResponse400A 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
200ProposalDetailResponse404Proposal 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 titlecontactIdstringContact (client) this proposal is forcontractStartDateobjectEngagement 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 HTMLcurrencystringISO 4217 currency codegraceWindowDaysnumberPer-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 textsenderMessagestringOptional 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 HTMLvalidUntilstringExplicit validity cutoff (ISO date-time)vatInclusivebooleanWhether entered amounts are VAT-inclusivevatRatestringProposal-level VAT rate (%), 0-100 with at most 2 decimal places
Responses
200ProposalResponse400Proposal 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
200ProposalActionResponse400Accepted proposal cannot be cancelled404Proposal not found409The 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 PDF404Proposal not found, or it has no rendered document yet500The 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
200ProposalEventsResponse404Proposal 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
200ProposalResponse404Proposal 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
200ProposalResponse400Accepted or not yet sent; or OTHER without a note404Proposal 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
200ProposalResponse404Proposal 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
200ProposalPreviewResponse404Proposal 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
200RemindProposalResponse400The client contact has no email address404Proposal not found409The proposal cannot be chased in its current state, or a reminder went out less than 48 hours ago503The 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
202SendProposalResponse404Proposal not found409The 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 IDIdempotency-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
202SendProposalResponse400No engagement body, no priced services, or unresolvable merge fields404Proposal not found409The 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 IDIdempotency-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 bucketdescriptionobjectWhat the service coversestimatedHoursstringEstimated 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
201ProposalDetailResponse400Proposal 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 IDserviceIdstringpathRequiredService line ID
Request body: UpdateProposalServiceDto
amountstringFeeamountTostringUpper bound of an ESTIMATE rangebillingFrequencyONE_OFF | WEEKLY | FORTNIGHTLY | MONTHLY | BIMONTHLY | QUARTERLY | SEMI_ANNUALLY | ANNUALLYdescriptionobjectWhat the service coversestimatedHoursstringEstimated hours (HOURLY only)feeTypeFIXED | HOURLY | ESTIMATEnamestringService nameratestringHourly rate (HOURLY only)sortOrdernumberDisplay positionvatRatestringThis 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
200ProposalDetailResponse400Proposal 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 IDserviceIdstringpathRequiredService line ID
Responses
200ProposalDetailResponse400Proposal 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
200ProposalDetailResponse400Proposal is not a DRAFT (`code: proposal-not-editable`), or duplicate / foreign / missing service ids404Proposal 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
200ClientEngagementsResponse400Missing 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 contactsearchstringqueryFree-text search, identical to the list endpoint’sdepartmentIdstringqueryOnly proposals filed to this departmentupdatedFromstringqueryLast 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
200ProposalStatsResponse400Invalid 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
200PublicProposalResponse404PublicProposalNotFoundResponse429Too 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
200AcceptProposalResponse400The signatory details are incomplete or invalid404PublicProposalNotFoundResponse409Expired, declined, or withdrawn - the message says which429Too many requests503Could 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
200DeclineProposalResponse404PublicProposalNotFoundResponse409Already accepted, already declined, or withdrawn - the message says which429Too 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
200404PublicProposalNotFoundResponse429Too 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 tokensupportingIdstringpathRequiredSupporting document iddispositionstringqueryRequired
Responses
200Supporting document file stream400The item is an external link, not a document we hold404PublicProposalNotFoundResponse429Too 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
200PublicProposalViewResponse404PublicProposalNotFoundResponse429Too 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
200ProposalsAccessMemberResponse404No 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
pagenumberquerypageSizenumberquerysearchstringqueryroleSIGNER | 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
emailstringpathRequiredpagestringqueryRequiredpageSizestringqueryRequired
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 builderisPublishedbooleanWhether 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
descriptionobjectisPublishedbooleannamestring
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
200CatalogueSectionResponse409The 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.defaultEstimatedHoursstringdefaultFeeTypeFIXED | HOURLY | ESTIMATEFee type (default FIXED)defaultRatestringdefaultVatRatestringThe 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.isPublishedbooleannamestringRequiredService 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
201CatalogueServiceResponse400An 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
200CatalogueServiceDetailResponse404No 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.defaultEstimatedHoursstringdefaultFeeTypeFIXED | HOURLY | ESTIMATEFee type (default FIXED)defaultRatestringdefaultVatRatestringThe 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.descriptionobjectisPublishedbooleannamestringsectionIdobjectserviceScheduleobject
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
200CatalogueServiceResponse409Its 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
200ServiceUsageResponse404No 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
200ImportStarterPackResponse400A service key that is not in this pack404Unknown pack
Signatures
GET/v1/signaturesList saved signatures
Get all saved signatures for the current user.
Opens with: API key
Responses
200SavedSignatureListResponseDtoList of saved signatures401Unauthorized
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 signatureisInitialsbooleanIs this an initials signaturenamestringRequiredInternal 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 successfully400Invalid data or limit reached401Unauthorized
GET/v1/signatures/{id}Get a saved signature
Get a specific saved signature by ID.
Opens with: API key
Parameters
idstringpathRequiredSignature ID
Responses
200SavedSignatureResponseDtoSaved signature401Unauthorized404Signature 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 signaturenamestringInternal label for this saved signaturesignerEmailstringDelivery 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 updated401Unauthorized404Signature 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 deleted401Unauthorized404Signature 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 default401Unauthorized404Signature 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 set401Unauthorized
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 set401Unauthorized
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 fields404Session not found409Session expired, revoked, or already completed429Too 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 result404Session not found409Locked out due to too many failed attempts429Too 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 electronicallyconsentVersionstringId 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 successfully400Not all required fields completed or missing consent404Session not found409Session already completed429Too 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 tokenemailstringqueryRequireddispositionstringqueryRequired
Responses
200Signed document file stream400Email parameter required403Email does not match recipient404Session not found409Envelope 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 URL404Session not found429Too 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 declined404Session not found409Session already completed or declined429Too 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 tokendocumentIdstringpathRequiredDocument ID
Responses
200Document file stream404Session or document not found409Authentication 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 tokenfieldIdstringpathRequiredField 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 successfully400Invalid 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 found409Field already completed429Too 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 tokenfieldIdstringpathRequiredField ID to clear
Responses
200ClearFieldResponseDtoField value cleared successfully404Session or field not found409Session already completed429Too 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 number404Session not found409Session expired or locked out429Too 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 number404Session not found409Session expired or locked out429Too 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 tokensupportingIdstringpathRequiredSupporting document iddispositionstringqueryRequired
Responses
200Supporting document file stream400The item is an external link, not a document we hold404Session or supporting document not found409Authentication required, or the envelope is closed429Too 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 | closedqueryticketNumberstringqueryExact ticket reference, used to resolve /support?ticket=TKT-000042 deep linkspagenumberquerylimitnumberquery
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 | othercontextSupportContextDtodescriptionstringRequiredWhat the user needs help withformInstanceIduuidClient-generated UUID, stable for one form submission (including retries). Omit and the server generates one, which de-duplicates nothing.prioritylow | medium | high | urgentsubjectstringRequiredTicket subject
Responses
201CreateSupportTicketResponseDto400Validation failure403Support is not enabled for this account429Rate limited: retry shortly503Support 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
200SupportTicketThreadResponseDto404No 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
201SupportReplyResponseDto409Ticket 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
200SupportingDocumentListResponseDto404Envelope 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 labeldocumentIdstringA 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
201SupportingDocumentResponseDto400Both or neither source given, or a non-https link404Envelope or document not found409The 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 idsupportingIdstringpathRequiredSupporting document id
Responses
204Detached404Envelope or supporting document not found409The 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
200SupportingDocumentEvidenceResponseDto404Envelope 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
200SupportingDocumentListResponseDto400The list is incomplete, or names something else404Envelope not found409The 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
200SupportingDocumentListResponseDto404Proposal 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 labeldocumentIdstringA 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
201SupportingDocumentResponseDto400Both or neither source given, or a non-https link404Proposal or document not found409The 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 idsupportingIdstringpathRequiredSupporting document id
Responses
204Detached404Proposal or supporting document not found409The 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
200SupportingDocumentEvidenceResponseDto404Proposal 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
200SupportingDocumentListResponseDto400The list is incomplete, or names something else404Proposal not found409The 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.emailstringRequiredfirstNamestringgrantProposalsbooleanGrant 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.lastNamestringroleOWNER | ADMIN | MEMBER | VIEWERRequiredvisibilityScopeOWN | 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
firstNamestringRequiredlastNamestringRequiredpasswordstringDeprecated. 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
200404Invitation not found, already used, or expired
GET/v1/team/membersList team members
Requires an OWNER or ADMIN session.
Opens with: API key
Responses
200TeamMemberListResponseDto401Not 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 sizecategorystringqueryFilter by categoryisActivebooleanqueryFilter by active statussearchstringquerySearch 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 templatesdefaultExpirynumberDefault expiry in daysdefaultMessagestringDefault message pre-populated into the envelope "message" field when this template is useddescriptionstringTemplate descriptiondocumentIdsstring[]RequiredDocument IDs to bundle into this template, in signing orderdocumentPackIdobjectA 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 activenamestringRequiredTemplate namerecipientRolesTemplateRecipientRoleDto[]RequiredRecipient roles for this templatesigningOrderPARALLEL | 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 successfully400Invalid input404Document not found
GET/v1/templates/{id}Get template by ID
Opens with: API key
Parameters
idstringpathRequired
Responses
200TemplateDetailResponseDtoReturns template details404Template not found
PUT/v1/templates/{id}Update a template
Opens with: API key
Parameters
idstringpathRequired
Request body: UpdateTemplateDto
categorystringCategory for organizing templatesdefaultExpirynumberDefault expiry in daysdefaultMessagestringDefault message pre-populated into the envelope "message" field when this template is useddescriptionstringTemplate descriptiondocumentIdsstring[]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 activenamestringTemplate namerecipientRolesTemplateRecipientRoleDto[]Recipient roles for this templatesigningOrderPARALLEL | SEQUENTIALSigning order for envelopes created from this template
Responses
200TemplateDetailResponseDtoTemplate updated successfully400Invalid input404Template not found
DELETE/v1/templates/{id}Delete a template
Opens with: API key
Parameters
idstringpathRequired
Responses
204Template deleted successfully404Template not found
POST/v1/templates/{id}/duplicateDuplicate a template
Opens with: API key
Parameters
idstringpathRequired
Responses
201TemplateDetailResponseDtoTemplate duplicated successfully404Template not found
POST/v1/templates/{id}/envelopesCreate an envelope from a template
Opens with: API key
Parameters
idstringpathRequired
Responses
201Envelope created successfully400Invalid input or missing recipient mappings404Template 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 settings401Not authenticated404Tenant 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 pagesprimaryColorstringPrimary brand colour in hex format (#RRGGBB)
Responses
200BrandingResponseDtoBranding updated400Invalid input401Not authenticated403Plan does not include custom branding404Tenant not found
POST/v1/tenants/branding/logoUpload branding logo
Requires an OWNER or ADMIN session.
Opens with: Signed-in session or API key
Request body
filebinaryLogo image file (PNG, JPEG or WebP, max 350KB)
Responses
201BrandingLogoResponseDtoLogo uploaded successfully400Invalid file401Not authenticated403Plan does not include custom branding
DELETE/v1/tenants/branding/logoRemove branding logo
Requires an OWNER or ADMIN session.
Opens with: Signed-in session or API key
Responses
200DeleteLogoResponseDtoLogo removed401Not authenticated404Tenant not found
GET/v1/tenants/currentGet current tenant
Opens with: Signed-in session or API key
Responses
200TenantResponseDtoReturns current tenant401Not authenticated404Tenant 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 nameprimaryColorstringPrimary 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 updated400Invalid input, or a non-writable property was sent401Not authenticated, or insufficient role403Sent `primaryColor` on a plan without custom branding404Tenant 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 profile401Not authenticated404Tenant 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 updated400Invalid input401Not authenticated, or insufficient role404Tenant not found
POST/v1/tenants/current/logoDeprecatedUpload tenant logo (deprecated)
Requires an OWNER or ADMIN session, and a plan that includes custom branding. DEPRECATED: identical to `POST /v1/tenants/branding/logo` — both write the same `logoData` columns. This one skipped the custom-branding plan check until 2026-08-07, which made it a route to a paid feature without the plan. It now carries the same gate. Use the branding route; this remains only for callers already integrated against it.
Opens with: Signed-in session or API key
Request body
filebinary
Responses
200LogoUploadResponseDtoLogo uploaded successfully400Invalid file401Not authenticated403Plan does not include custom branding
DELETE/v1/tenants/current/logoDeprecatedRemove tenant logo (deprecated)
Requires an OWNER or ADMIN session. DEPRECATED: identical to `DELETE /v1/tenants/branding/logo`. Removal is deliberately NOT plan-gated on either route — a tenant that drops off a branding plan must still be able to take its logo down.
Opens with: Signed-in session or API key
Responses
200DeleteLogoResponseDtoLogo removed401Not authenticated404Tenant not found
GET/v1/tenants/current/statsGet current tenant statistics
Opens with: Signed-in session or API key
Responses
200TenantStatsDtoReturns tenant statistics401Not authenticated
Users
GET/v1/users/meGet current user profile
Opens with: Signed-in session or API key
Responses
200UserProfileResponseDtoReturns current user401Not authenticated
PATCH/v1/users/meUpdate current user profile
Opens with: Signed-in session or API key
Request body: UpdateProfileDto
avatarUrlstringAvatar URLfirstNamestringFirst namelastNamestringLast namephonestringPhone numbersenderDisplayNameOverrideobjectPer-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 updated400Invalid input401Not authenticated
PATCH/v1/users/me/passwordChange password
Opens with: Signed-in session or API key
Request body: ChangePasswordDto
currentPasswordstringRequiredCurrent passwordnewPasswordstringRequiredNew password (min 8 chars, must include uppercase, number, and special character)
Responses
200MessageResponseDtoPassword changed400Invalid input or password requirements not met401Not 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 result404Envelope 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
200VersionResponseDto401The 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 tourlstringRequiredThe https URL to receive webhook events. Must not resolve to a private, loopback or link-local address.
Responses
201WebhookResponseDtoWebhook created successfully400Invalid 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 details404Webhook 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 toisActivebooleanWhether the webhook is activeurlstringThe https URL to receive webhook events. Must not resolve to a private, loopback or link-local address.
Responses
200WebhookResponseDtoWebhook updated404Webhook not found
DELETE/v1/webhooks/{id}Delete a webhook
Remove a webhook endpoint
Opens with: API key
Parameters
idstringpathRequired
Responses
200Webhook deleted404Webhook not found
GET/v1/webhooks/{id}/deliveriesGet webhook deliveries
View delivery history for a webhook
Opens with: API key
Parameters
idstringpathRequiredlimitnumberqueryMaximum number of deliveries to return (default: 50)
Responses
200WebhookDeliveryListResponseDtoList of deliveries404Webhook 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
idstringpathRequireddeliveryIdstringpathRequired
Responses
200Delivery re-queued400Cannot retry this delivery404Webhook 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 regenerated404Webhook 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 result404Webhook 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