Skip to content

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 /sales route.

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:

AxisFieldMeaning
CommercialcommercialStatusDRAFT → SENT → ACCEPTED / DECLINED / EXPIREDCONVERTED, plus terminal VOIDED / CANCELLED. Forward-only.
PaymentpaymentStatusNOT_REQUIRED, UNPAID, PARTIALLY_PAID, PAID, PARTIALLY_REFUNDED, REFUNDED, FAILED, CANCELLED. Derived from the payment ledger.
FulfillmentfulfillmentStatusNOT_REQUIRED, UNFULFILLED, PARTIALLY_FULFILLED, FULFILLED, CANCELLED. Skeleton in M3.5 — bookings (M4) and subscriptions (M5) own the concrete transitions on top of this axis.
SignaturesignatureStatusNOT_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; displayNumberKind tells 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:

ActionTransition
POST …/quotes/{id}/acceptSENT → ACCEPTED
POST …/{quotes,invoices}/{id}/voidSENT | ACCEPTED → VOIDED
POST …/{quotes,invoices}/{id}/cancelDRAFT | SENT → CANCELLED
POST …/{quotes,invoices}/{id}/revisesupersede the sent revision, reopen a DRAFT clone
POST …/quotes/{id}/convertACCEPTED 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}/payments
  • POST …/{invoices,orders}/{id}/refunds
  • GET …/{invoices,orders}/{id}/payments — the ledger, newest-first

Derived money on the record: amountPaidInCents (gross), refundedInCents, and balanceDueInCentsmax(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 a DRAFT packet
  • POST /merchant/commerce/contracts/{id}/send — allocate C-…, mint the signer link, DRAFT → SENT
  • GET /merchant/commerce/contracts[/{id}] — list / read with signatures

Customer surface

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.

RoutePurpose
GET /customer/commerce/{quotes,invoices}/{tokenOrId}read the document revision the token addresses
POST /customer/commerce/quotes/{tokenOrId}/acceptaccept (SENT → ACCEPTED)
POST /customer/commerce/quotes/{tokenOrId}/declinedecline (SENT → DECLINED)
POST /customer/commerce/invoices/{tokenOrId}/paystart an online card payment — returns a Stripe PaymentIntent client secret
GET /customer/commerce/contracts/{tokenOrId}read the contract packet
POST /customer/commerce/contracts/{tokenOrId}/signfirst-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.