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.
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.
/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
/quotes201 Created{
"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 }
]
}{
"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:
| Line | Net | Tax 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 |
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:
{
"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:
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:400curl -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"Sending freezes the quote into a contract
/quotes/{quote_id}/sendTwo 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.
{
"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
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:
$ 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 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:
{"detail":"Only an accepted quote can be invoiced"}
HTTP:400| Route | Effect |
|---|---|
POST /invoices/{id}/issue | Draft → open, assigns the number and due date |
POST /invoices/{id}/send | Emails it to the billing contact with the PDF attached |
POST /invoices/{id}/record-payment | Records a payment taken outside the system — bank transfer, cheque |
POST /invoices/{id}/checkout | Creates a Stripe Checkout session on your own key — 400s today, because Stripe cannot be connected |
POST /invoices/{id}/void | Cancels it |
PUT /invoices/{id}/lines | Replaces the line items while still editable |
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.3Customers 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
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"}'{
"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"
}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:
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:400If 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
Contracts and e-sign
The signing flow that accepts a quote, including anchors and the audit trail.
Webhooks and events
The Stripe receiver, and the only webhook here that verifies signatures.
Calendar and meetings
Book the kickoff once the paperwork is done.
Plans and self-hosting
The other invoices — what you pay VxCloud for SalesShift, on routes that share nothing with the ones above.
REST API reference
All 17 quote and 15 payment routes with parameters and error cases.