Sales documents: quotes, invoices & contracts
The shared-commerce surface is one document engine behind quotes, invoices,
admin-created orders, and contracts. A merchant drafts a document, prices it,
sends it, and the customer accepts / pays / signs through a token-scoped link.
Every route below is published in the API reference; examples
reuse the api() helper.
The merchant surface is /api/v1/merchant/commerce/* (merchant JWT +
x-organization-slug); the customer-facing surface is
/api/v1/customer/commerce/* (token-scoped) plus
/api/v1/customer/account/commerce (signed-in My Account).
The admin UI names this area Sales; the API namespace is
commerce. They are the same feature — don't expect a/salesroute.
The record and its four status axes
A commerce record (commerce_records) is the document. Its state is tracked
on four independent axes — they move on their own schedules, so read the one
you care about rather than inferring from another:
| Axis | Field | Meaning |
|---|---|---|
| Commercial | commercialStatus | DRAFT → SENT → ACCEPTED / DECLINED / EXPIRED → CONVERTED, plus terminal VOIDED / CANCELLED. Forward-only. |
| Payment | paymentStatus | NOT_REQUIRED, UNPAID, PARTIALLY_PAID, PAID, PARTIALLY_REFUNDED, REFUNDED, FAILED, CANCELLED. Derived from the payment ledger. |
| Fulfillment | fulfillmentStatus | NOT_REQUIRED, UNFULFILLED, PARTIALLY_FULFILLED, FULFILLED, CANCELLED. Skeleton in M3.5 — bookings (M4) and subscriptions (M5) own the concrete transitions on top of this axis. |
| Signature | signatureStatus | NOT_REQUIRED, PENDING, SIGNED, DECLINED, EXPIRED, CANCELLED. Derived from the attached contract packet(s). |
Content lives on revisions (commerce_record_revisions). Only the current
DRAFT revision is mutable. Sending freezes that revision — its
contentSnapshot, pricingSnapshot, and paymentTermsSnapshot become
immutable — so the amounts a customer sees never re-derive from live catalog
or tax config later (the priced-snapshot contract, ADR-012).
Display numbers
While a document is DRAFT it has no displayNumber. At send/issue a
human-facing number is allocated from a per-org series and is stable for the life
of the record:
- Quotes →
Q-1001, invoices →INV-1001, contracts →C-1001(the exact prefixes are tenant-configurable;displayNumberKindtells you the series). - Customers address their My Account documents by this number, not by database id.
Create a draft (merchant)
Creating and editing a document requires a per-type permission group:
shared-commerce:quotes, shared-commerce:invoices, or (for manual orders)
orders:operate. OWNER/ADMIN bypass the group check. Reads are open to any
member.
const quote = await api("/merchant/commerce/quotes", {
method: "POST",
headers: { "x-idempotency-key": crypto.randomUUID() }, // optional, safe retries
body: JSON.stringify({
customerName: "Dana Reyes",
customerEmail: "dana@example.com",
currency: "usd",
// externalSource + externalId let you import from an ERP idempotently;
// (organization, externalSource, externalId) is unique.
externalSource: "acme-erp",
externalId: "SO-4821",
}),
});The three creates — POST /merchant/commerce/{quotes,invoices,orders} — and the
manual payment/refund writes all accept an optional x-idempotency-key; a
retried request with the same key returns the original result instead of
creating a duplicate (see Idempotency).
Build the document
Add priced lines and optional groups (proposal sections) to a DRAFT, then
reprice. Editing a non-DRAFT document is rejected 409 COMMERCE_REVISION_IMMUTABLE.
await api(`/merchant/commerce/${quote.id}/lines`, {
method: "POST",
body: JSON.stringify({
name: "Trail Pack 38L",
quantity: 2,
unitPriceInCents: 18900,
category: "PRODUCT",
}),
});
await api(`/merchant/commerce/${quote.id}/reprice`, { method: "POST" });Line and group editing: POST/PATCH/DELETE …/{id}/lines[/{lineId}] and
…/{id}/groups[/{groupId}].
Manual overrides
A reasoned manual override on price, discount (line or document), or tax is a
high-risk action — it requires a reason and is captured on the activity
timeline. Set with PUT, clear with DELETE:
…/{id}/lines/{lineId}/price-override— line unit price…/{id}/lines/{lineId}/discount-override,…/{id}/discount-override— discount…/{id}/tax-override— document tax treatment
Every override repricing runs through the same snapshot so the frozen amounts stay authoritative.
Send the document
Sending freezes the current revision, allocates the displayNumber, moves
commercialStatus DRAFT → SENT, and mints a customer-action token — the
credential for the customer link. The token is returned once; store the link,
not the token.
const { record, customerAction } = await api(
`/merchant/commerce/quotes/${quote.id}/send`,
{
method: "POST",
body: JSON.stringify({ /* send options */ }),
},
);
// `record` is the updated document detail; `customerAction` is the minted link:
// customerAction.token — raw token, returned ONCE
// customerAction.expiresAt — when the link expires (document expiry + grace)Building the customer link. The token addresses the hosted document-view page
(#1385): /quote/<token> (or /invoice/…, /contract/…). Its origin is the
customer-action origin, not your storefront origin — the platform-hosted
<slug>.litecheckout.io by default, or your trusted BYO override
(checkoutSettings.customerActionBaseUrl) when set. litecommerce resolves this
for you and delivers the fully-formed link in the send email (below); if you
render the link yourself, build it against the customer-action origin:
https://<slug>.litecheckout.io/quote/<customerAction.token>
Sending also enqueues the customer email — with the resolved link — through the durable outbox (#598).
Lifecycle transitions (merchant)
From the merchant side you can act on behalf of the customer or retire a
document. All are forward-only and reject an out-of-state call with 409:
| Action | Transition |
|---|---|
POST …/quotes/{id}/accept | SENT → ACCEPTED |
POST …/{quotes,invoices}/{id}/void | SENT | ACCEPTED → VOIDED |
POST …/{quotes,invoices}/{id}/cancel | DRAFT | SENT → CANCELLED |
POST …/{quotes,invoices}/{id}/revise | supersede the sent revision, reopen a DRAFT clone |
POST …/quotes/{id}/convert | ACCEPTED quote → committed order, quote → CONVERTED |
A revise keeps the displayNumber, stamps supersededAt on the prior sent
revision, and clones its content into a fresh DRAFT at revisionNumber + 1. The
superseded revision's frozen snapshot is never mutated — an outstanding customer
link to it reads 409 COMMERCE_REVISION_STALE.
Payments & refunds
Record a manual/offline payment or refund against an issued invoice or order.
Both post to the payment ledger, are idempotent on x-idempotency-key, require
shared-commerce:manual-payments, and a refund can't exceed net paid:
POST …/{invoices,orders}/{id}/paymentsPOST …/{invoices,orders}/{id}/refundsGET …/{invoices,orders}/{id}/payments— the ledger, newest-first
Derived money on the record: amountPaidInCents (gross), refundedInCents, and
balanceDueInCents — max(0, totalInCents − amountPaidInCents), based on gross
payments. A refund does not re-open the balance (ADR-013); it's tracked on
refundedInCents + paymentStatus. Online card payment is a customer action
(below).
Money bounds. Commerce amounts are 32-bit integer cents. Document totals cap at 2,147,483,647 cents (~$21.47M); per-amount line inputs at ±$10M. A pricing write past these bounds is rejected
400.
Activity timeline
GET /merchant/commerce/{id}/activity is a cursor-paginated, newest-first feed of
lifecycle events (sends, accepts, overrides, payments, revisions). Event metadata
is projected through a fail-closed allow-list — signer IP/UA and other audit-only
values stay off the timeline.
Contracts
A contract packet attaches a signature request to a record. First-party (in-app) signing is the shipped mode.
POST /merchant/commerce/contracts— create aDRAFTpacketPOST /merchant/commerce/contracts/{id}/send— allocateC-…, mint the signer link,DRAFT → SENTGET /merchant/commerce/contracts[/{id}]— list / read with signatures
Customer surface
Token-scoped links
The customer receives a link whose action token is the credential — these
routes carry only the x-organization-slug tenant boundary, no customer session.
Reads never consume the token; state-changing actions are single-use and
idempotent on replay.
| Route | Purpose |
|---|---|
GET /customer/commerce/{quotes,invoices}/{tokenOrId} | read the document revision the token addresses |
POST /customer/commerce/quotes/{tokenOrId}/accept | accept (SENT → ACCEPTED) |
POST /customer/commerce/quotes/{tokenOrId}/decline | decline (SENT → DECLINED) |
POST /customer/commerce/invoices/{tokenOrId}/pay | start an online card payment — returns a Stripe PaymentIntent client secret |
GET /customer/commerce/contracts/{tokenOrId} | read the contract packet |
POST /customer/commerce/contracts/{tokenOrId}/sign | first-party sign |
Customer document reads are a whitelist projection — no database ids, no
customerEmail/phone, no internal notes, no raw pricing rationale.
My Account (signed-in)
For a logged-in customer, /customer/account/commerce lists their quotes and
invoices and /customer/account/commerce/{displayNumber} reads one by its public
number. These carry the customer session bearer and the org header (unlike the
token links), like the rest of My Account.
Adjacent lifecycle owners
The commerce record remains the shared money/document authority, while concrete features own their operational lifecycle. Bookings now project schedule, capacity, assignment, and physical fulfillment into this layer without copying its payment ledger; see Booking availability, holds & lifecycle. Subscriptions own cadence, entitlements, and renewal orders through their separate contract. Nothing above teaches a route that isn't in the API reference.