Skip to content
SSalesShiftDocs

Guide

Quotes and invoicing

Quote to cash, with one design decision that shapes everything else: a quote is accepted by signature, not by a button.There is no “mark as accepted” endpoint. The customer signs the quote’s contract, and that signature is what creates the invoice and any subscription.

This guide walks the whole chain and every number in it is real: a quote priced at $2,808, blocked by a discount threshold, approved, sent, signed, auto-invoiced, PDF’d, emailed and part-paid.

Executed end to endQuote → signature → invoice → paymentStripe: cannot be connected yet

Products

Lines can be free-form, but a product catalogue gives you consistent pricing, tax rates and billing intervals. Prices are integer cents throughout — there are no floats in the money path.

GET/products
Real response — two products
[
  { "id": "1a4e7753-b0ac-436e-88a1-88ae264105ed",
    "sku": "VX-ONB", "name": "Onboarding (one-off)",
    "kind": "one_time",
    "unit_price_cents": 150000, "unit_price": "1500.00",
    "unit_price_display": "1,500.00 USD", "currency": "USD",
    "billing_interval": null, "tax_rate": 20.0, "is_active": true },

  { "id": "81ad7f6a-e8eb-4c05-9285-c1ad0842b477",
    "sku": "VX-PRO", "name": "VxCloud Pro seat",
    "kind": "recurring",
    "unit_price_cents": 9900, "unit_price": "99.00",
    "unit_price_display": "99.00 USD", "currency": "USD",
    "billing_interval": "month", "billing_interval_count": 1,
    "tax_rate": 20.0, "is_active": true }
]

kind matters later: recurring lines on an accepted quote create a subscription, one_time lines do not.

Build a quote

POST/quotes201 Created
quote.json — executed
{
  "title": "Docs walkthrough - Pro rollout",
  "contact_id": "613f2aec-14b8-4281-9ced-19fc2c8d38a8",
  "currency": "USD",
  "valid_until": "2026-09-30",
  "terms": "Prices exclude local withholding taxes.",
  "lines": [
    { "product_id": "81ad7f6a-e8eb-4c05-9285-c1ad0842b477", "quantity": 10 },
    { "product_id": "1a4e7753-b0ac-436e-88a1-88ae264105ed", "quantity": 1,
      "discount_kind": "percent", "discount_value": 10 }
  ]
}
Real response (HTTP 201, trimmed)
{
  "id": "5767add2-7640-4e9f-84bc-f320473c9e5a",
  "number": "Q-00012",
  "status": "draft",
  "currency": "USD",
  "subtotal_cents": 249000,
  "discount_cents": 15000,
  "tax_cents": 46800,
  "total_cents": 280800,
  "total_display": "2,808.00 USD",
  "discount_percent": 6.02,
  "valid_until": "2026-09-30",
  "public_token": "1a4b336f7986472f710749b184498492e1f592e8",
  "is_locked": false
}

The arithmetic, so you can check your own:

LineNetTax 20%Line total
10 × VxCloud Pro seat @ $99$990.00$198.00$1,188.00
1 × Onboarding @ $1,500 less 10%$1,350.00$270.00$1,620.00
Total$2,340.00$468.00$2,808.00
subtotal_cents 249000 is the pre-discount gross; discount_cents 15000 is the $150 taken off the onboarding line.

Lines can be replaced wholesale with PUT /quotes/{id}/lines (max 200 per quote) while the quote is unlocked.

Discount approval

Organisations set a discount ceiling. Above it, sending is blocked until someone approves. The threshold is visible on the settings endpoint:

Real response — GET /settings/quotes
{
  "reporting_currency": "USD",
  "send_pool_rotation_enabled": true,
  "quote_approval_discount_pct": 5
}

The quote above discounts 6.02% overall. Attempting to send it was refused, with the numbers in the message:

Executed
curl -s -X POST "$API/quotes/5767add2-…/send" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'

{"detail":"Approval required before sending: Discount of 6.02% exceeds the 5% limit"}
HTTP:400
Executed — submit, approve, send
curl -s -X POST "$API/quotes/5767add2-…/submit-for-approval" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'

{"success":true,"approved_automatically":false,
 "reason":"Discount of 6.02% exceeds the 5% limit","data":{"status":"pending_approval",…}}

curl -s -X POST "$API/quotes/5767add2-…/approve"  -H "Authorization: Bearer $TOKEN" …
  → status "approved"

curl -s -X POST "$API/quotes/5767add2-…/send"     -H "Authorization: Bearer $TOKEN" …
  → status "sent"
A quote inside the threshold returns approved_automatically: true and skips the queue. POST /quotes/{id}/decline-approval rejects it instead.

Sending freezes the quote into a contract

POST/quotes/{quote_id}/send

Two rules this endpoint exists to keep:

  • Sending means delivering. The quote is marked sentonly once the mail server has accepted the message. If delivery fails, the contract document and its signing link are still created — so you can copy the link and deliver it by hand — but the quote stays where it was and the response tells you why, rather than a status claiming a customer has something they do not.
  • One document per quote.A second send reuses the existing contract row. Minting a fresh one would leave the first document’s signing tokens live on a superseded price, and the customer could sign either.
Real response (trimmed)
{
  "success": true,
  "data": { "number": "Q-00012", "status": "sent", "total_display": "2,808.00 USD" },
  "contract_doc_id": "4448c3e1-6a40-4a58-b8b5-81d0197fa7b5",
  "sign_url_b": "/sign/c45521e832784d50fc6287f738ba65815e929f37",
  "public_url": "/quote/1a4b336f7986472f710749b184498492e1f592e8",
  "delivered": true,
  "sent_to": ["…"],
  "delivery_error": ""
}

The customer can also view the quote without signing at GET /quotes/public/{token}. First view flips the status from sent to viewed and stamps viewed_at.

Acceptance is the signature

Executed — customer signs, then the seller countersigns
curl -s -X POST "$API/contracts/sign/c45521e8…" -H 'Content-Type: application/json' \
  -d '{"name":"Sam Rivera","signature":"Sam Rivera","style":"cursive","consent":true}'
{"success":true,"status":"partially_signed","both_signed":false}

curl -s -X POST "$API/contracts/sign/650054cf…" -H 'Content-Type: application/json' \
  -d '{"name":"Sam Rivera","signature":"Sam Rivera","style":"cursive","consent":true}'
{"success":true,"status":"completed","both_signed":true}

The moment that completed, three things happened by themselves:

Executed — the state afterwards
$ curl -s "$API/quotes/5767add2-…" -H "Authorization: Bearer $TOKEN"
status accepted   accepted_at 2026-08-06T18:38:44.537466+00:00

$ curl -s "$API/invoices" -H "Authorization: Bearer $TOKEN"
INV-00007  open  2,808.00 USD  due 2026-08-20  quote: 5767add2-7640-4e9f-84bc-f320473c9e5a

$ curl -s "$API/subscriptions" -H "Authorization: Bearer $TOKEN"
{"id":"db7e28a9-…","status":"active","quote_id":"5767add2-…",
 "mrr_cents":99000,"arr_cents":1188000,"mrr_display":"990.00 USD",
 "billing_interval":"month","billing_interval_count":1,
 "current_period_start":"2026-08-06T18:38:44Z",
 "current_period_end":"2026-09-05T18:38:44Z"}
The invoice covers the whole quote. The subscription covers only the recurring line — 10 seats × $99 = $990 MRR, $11,880 ARR.

The follow-up work is wrapped so that a downstream failure can never invalidate a signature that legitimately happened. If invoicing fails, the signature still stands and the failure is logged.

Invoices

Invoices also exist independently — POST /invoices for a standalone one, or POST /invoices/from-quote/{quote_id} to convert manually. That route requires an accepted quote:

Executed — against a quote that was only 'sent'
{"detail":"Only an accepted quote can be invoiced"}
HTTP:400
RouteEffect
POST /invoices/{id}/issueDraft → open, assigns the number and due date
POST /invoices/{id}/sendEmails it to the billing contact with the PDF attached
POST /invoices/{id}/record-paymentRecords a payment taken outside the system — bank transfer, cheque
POST /invoices/{id}/checkoutCreates a Stripe Checkout session on your own key — 400s today, because Stripe cannot be connected
POST /invoices/{id}/voidCancels it
PUT /invoices/{id}/linesReplaces the line items while still editable

PDF

Executed
curl -s -D - -o invoice.pdf "$API/invoices/d8fda22e-…/pdf" \
  -H "Authorization: Bearer $TOKEN"

HTTP/1.1 200 OK
content-type: application/pdf
content-length: 26288

$ head -c 8 invoice.pdf
%PDF-1.3

Customers get the same document without a credential at GET /invoices/public/{token}/pdf, alongside a hosted payment page. The invoice object carries both public_token and hosted_url.

Recording a payment

Executed — a partial payment
curl -s -X POST "$API/invoices/d8fda22e-…/record-payment" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"amount_cents":100000,"method":"bank_transfer","reference":"DOCS-TEST-001"}'
Real response (trimmed)
{
  "number": "INV-00007",
  "status": "partially_paid",
  "total_cents": 280800,
  "amount_paid_cents": 100000,
  "amount_due_cents": 180800,
  "total_display": "2,808.00 USD",
  "amount_due_display": "1,808.00 USD"
}
Status moved open → partially_paid on its own. A payment covering amount_due closes it to paid.

Stripe is bring-your-own-key — and not connectable yet

SalesShift does not process your customers’ payments. Card payment on an invoice is designed to run through your Stripe account, on a key you store as an integration, with funds settling to you directly. With nothing connected, checkout refuses rather than pretending:

Executed
curl -s -X POST "$API/invoices/d8fda22e-…/checkout" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'

{"detail":"Connect Stripe under Settings → Integrations first"}
HTTP:400

If the connection is shipped, the receiver to point Stripe at is POST /webhooks/stripe/{integration_id}. Unlike the email provider webhooks, that one does verify signatures: with no endpoint secret stored it refuses outright rather than trusting the body, and a bad signature is a 400. See Webhooks and events.

Currency and reporting

Quotes and invoices carry their own currency, and each also carries reporting_total_cents with the fx_rate and fx_as_of used to convert into the organisation’s reporting_currency. The rate is stamped on the record, so a historical total does not move when rates do.

Common problems

400 Only an accepted quote can be invoiced

The quote has not been signed. sent and viewed are not acceptance. Send it, get the counterparty to sign the contract at sign_url_b, countersign as party A, and the invoice will already exist by the time you look — there is usually no need to call from-quote at all.

400 Approval required before sending

The overall discount exceeds quote_approval_discount_pct. Read discount_percenton the quote — it is computed across the whole document, so several small line discounts can add up past the threshold even when none individually looks large. Either reduce the discount or run submit-for-approval and approve.

400 A sent quote cannot be edited — duplicate it instead

Sent, accepted and voided quotes are locked; is_locked on the object tells you before you try. This is what stops a price changing underneath a signing link. Duplicate, amend the copy, and send that.

The quote sent but the customer never received it

Check delivered and delivery_errorin the send response. When delivery fails, the quote deliberately stays in its previous status while the signing link is still created — take sign_url_b from the response and deliver it by hand while you fix the mailbox.

Totals are off by a cent against your own calculation

Everything is integer cents and tax is applied per line, not to the document total. Ten lines each rounding independently will not match a single tax calculation on the grand total. Reconcile against subtotal_cents, discount_cents and tax_cents per line rather than recomputing from the display strings.

An accepted quote produced no subscription

Only lines whose product kind is recurring create one. A quote of purely one_time lines correctly produces an invoice and no subscription. Check billing_interval on the product.

Next