Stitchwork Docs
stitchwork.io

Core concepts

Quotes

A quote is a proposal with a customer's name on it and a link only they have. This page covers the whole lifecycle: building, totals, sending, the public page, payment, and turning an accepted quote into an order.

The lifecycle

              ┌────── POST /quotes/{id}/send ──────┐
              │                                    ▼
  ┌───────┐   │   ┌──────┐  customer accepts  ┌──────────┐   pays   ┌──────┐
  │ draft │───┘──▶│ sent │───────────────────▶│ accepted │─────────▶│ paid │
  └───┬───┘       └──┬───┘                    └────┬─────┘          └──────┘
      │              │  customer declines          │
      │              └────────────▶ ┌──────────┐   │ convert-to-order
      │                             │ declined │   ▼
      │  POST /quotes/{id}/cancel   └──────────┘  ┌───────┐
      └──────────────▶ ┌───────────┐              │ order │
                       │ cancelled │              └───────┘
                       └───────────┘
                                        valid_until passes ──▶ expired
Editable only in draft. Acceptance and decline are only possible from sent; payment from sent or accepted.
Cancel ≠ delete

POST /quotes/{id}/cancel is the soft path: the quote stops being actionable but stays on the customer's history and in your totals. DELETE /quotes/{id} removes it, its lines and its timeline for good, and moves every total it counted towards. Delete exists so trial data can be cleared, not as a nicer word for cancel. It is owner-only.

Creating a quote

Every quote needs a channel and a customer. Everything else has a default.

curl -X POST https://app.stitchwork.io/api/v1/quotes \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{
    "channel_id": 2,
    "customer_id": 14,
    "currency": "GBP",
    "discount_type": "percent",
    "discount_value": "10",
    "tax_rate": 20,
    "tax_inclusive": true,
    "notes_customer": "Delivery week commencing 14 April.",
    "notes_internal": "Held the walnut back from the Mayfair floor stock.",
    "valid_until": "2026-09-30",
    "visitor_token": "optional __sw_v value",
    "line_items": [
      {"sku":"CONCERTO-WAL-OO","description":"Concerto in walnut, OO","quantity":1,"unit_price":"2850.00","product_variant_id":88}
    ]
  }'
FieldDefaultNotes
channel_idRequired.
customer_idRequired.
currencyGBP3-letter ISO code.
discount_typenoneamount or percent. Anything else is treated as no discount.
discount_valuenullMoney for amount, 0–100 for percent.
tax_rate0A percentage, e.g. 20.
tax_inclusivefalseSee Totals and tax.
valid_until+30 daysAny parseable date.
notes_customernullShown on the public quote page.
notes_internalnullNever sent to the customer — stripped from every public response.
line_items[]Created in order. See below.
visitor_tokenStitches the browser to this customer and stamps ad attribution onto the quote. Soft-failing.

The response carries a generated quote_number (e.g. Q-2026-000007) and a public_token.

Line items

Lines are snapshots. Description, SKU and unit price are stored on the line, so editing a product later never rewrites a quote you already sent.

FieldNotes
descriptionWhat the customer reads. Blank becomes "Line item".
quantityInteger, minimum 1.
unit_priceMoney as a string. Non-numeric becomes 0.00.
skuOptional but recommended — it is what carries through to fulfilment.
product_variant_idOptional. Setting it links the line to the catalogue, which is what bumps the customer's affinity for that product and feeds the bestseller signal.
# add a line to a draft
curl -X POST https://app.stitchwork.io/api/v1/quotes/7/lines \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"product_variant_id":88,"sku":"CONCERTO-WAL-OO","description":"Concerto in walnut, OO","quantity":1,"unit_price":"2850.00"}'

# change it
curl -X PATCH https://app.stitchwork.io/api/v1/quotes/7/lines/31 \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"quantity":2,"unit_price":"2750.00","description":"Concerto in walnut, OO"}'

# remove it
curl -X DELETE https://app.stitchwork.io/api/v1/quotes/7/lines/31 -b cookies.txt

Totals recalculate on every line change, inside the same transaction. Adding, editing or removing a line on a non-draft quote returns 422.

Totals and tax

Four numbers, computed in this order:

subtotal        = Σ (quantity × unit_price)

discount_amount = discount_type == 'amount'   → min(discount_value, subtotal)
                  discount_type == 'percent'  → subtotal × clamp(discount_value, 0, 100) / 100
                  otherwise                   → 0

taxable         = max(0, subtotal − discount_amount)

Then the two tax modes diverge:

Modetax_amounttotalUse when
tax_inclusive: false
default
taxable × rate% subtotal − discount + tax Your prices are ex-VAT and tax is added on top. Typical B2B.
tax_inclusive: true taxable − taxable / (1 + rate/100)
(informational)
taxable Your shelf prices already contain VAT. Typical UK/EU retail.

All four are stored as fixed 2-decimal values, so what you see is what is charged. A discount larger than the subtotal is capped rather than producing a negative total.

Sending

curl -X POST https://app.stitchwork.io/api/v1/quotes/7/send -b cookies.txt

This moves the quote to sent, stamps sent_at, and emails the customer a link to their public quote page. The customer must have an email on file, or you get 422.

Mail delivery is best-effort: if the mailer is misconfigured the status change still happens and the failure is logged, so you can copy the link by hand rather than lose the state transition. Grab the link any time:

curl https://app.stitchwork.io/api/v1/quotes/7/qr -b cookies.txt
# {"data":{"url":"https://app.stitchwork.io/q/9f2c…","token":"9f2c…"}}

Timeline notes

curl -X POST https://app.stitchwork.io/api/v1/quotes/7/notes \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"body":"Customer asked about a hard case.","visible_to_customer":false}'

The quote timeline records every state change, line change and note. Notes with visible_to_customer: true appear on the public page; everything else is internal.

The public quote page

/q/{token}. No login, no account. The 32-character token is the authentication, and it is unguessable.

Fields stripped from every public response:

What the customer can do:

ActionEndpointAllowed from
AcceptPOST /quotes/public/{token}/acceptsent
DeclinePOST /quotes/public/{token}/declinesent — optional reason
Pay by cardPOST /quotes/public/{token}/paysent or accepted, Stripe connected
Share browsingPOST /quotes/public/{token}/join-codeAny state, if a join URL is configured

Acting on a quote in the wrong state returns 409 with a plain-English reason.

Public pages ignore your billing state

Locking a workspace never breaks a quote the merchant's own customer is halfway through paying for. Public quote routes never resolve a workspace through the access gate.

"Find my quote" on your own site

A customer who has lost the email can retrieve their quote from a form on your site, authenticated by your public key plus the email on the quote:

curl -X POST https://app.stitchwork.io/api/v1/quotes/public/lookup \
  -H "Authorization: Public pk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"number":"Q-2026-000007","email":"ada@example.com"}'

CORS-enabled, so it can be called straight from the browser. Any miss — wrong number, wrong email, missing key — returns an identical generic 404, and the email comparison runs even when there is no quote so the timing is the same either way. That is deliberate: it stops anyone walking Q-2026-000001 through Q-2026-009999.

Taking payment

Optional, and it settles to your Stripe account, not ours. Onboarding creates a Stripe Express account and returns a Stripe-hosted link to complete it:

curl -X POST https://app.stitchwork.io/api/v1/billing/connect/onboard \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"country":"GB"}'
# {"data":{"url":"https://connect.stripe.com/setup/…","account_id":"acct_…"}}

curl https://app.stitchwork.io/api/v1/billing/connect/status -b cookies.txt
# {"data":{"account_id":"acct_…","charges_enabled":true,"payouts_enabled":true,"country":"GB"}}

Owner only. Once charges_enabled is true, the pay button appears on public quote pages.

Paying creates a Stripe PaymentIntent as a direct charge on your account, with a Stitchwork application fee on top (a basis-point rate set per deployment — 300 bps = 3% by default). Stripe's webhook then marks the quote paid and records the charge id. A failed payment lands as a note on the quote's timeline rather than changing its status.

Reconciliation is asynchronous on purpose

The quote is marked paid by the Stripe webhook, not by the browser coming back to a return page. A customer who pays and immediately closes their phone still ends up with a paid quote.

Handing off to your own checkout

If you would rather the customer completed on your site, set a URL template in Settings → Quote completion. The public page then shows a button pointing at the resolved URL.

curl -X PATCH https://app.stitchwork.io/api/v1/settings/quote-complete-url \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"url":"https://example.com/cart/prefill?ref={quote_number}&email={email}&total={total}"}'

Tokens use {name} or [name], are case-insensitive, and are URL-encoded on substitution:

TokenAlso acceptsValue
{quote_number}{number}, {order_id}, {name}Q-2026-000007
{quote_id}{id}Numeric id
{token}{public_token}The public token
{customer_email}{email}Email on the quote
{customer_name}Name on the quote
{total}Quote total
{currency}3-letter code

An unknown token is left visible in the resolved URL rather than silently stripped, so a typo shows up the first time you test it. Maximum 1024 characters; must start with http:// or https://.

Browsing inside the builder

The quote builder shows what the customer has been looking at, with matched products joined inline so each row can be added to the quote in one click:

curl "https://app.stitchwork.io/api/v1/quotes/7/browsing?types=view_product,add_to_cart" -b cookies.txt

Fifty most recent events for the quote's customer. Nothing appears here until that customer has a stitched visitor.

Quotes created from your website

Let a customer build a basket online and walk into the store to an agent who already has the draft open. Server-to-server, authenticated with the secret key:

curl -X POST https://app.stitchwork.io/api/v1/quotes/external \
  -H "Authorization: Bearer sk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channel_id": 7,
    "customer": {"email":"ada@example.com","name":"Ada Lovelace","phone":"+44…"},
    "visitor_token": "the __sw_v cookie value, if you have it",
    "currency": "GBP",
    "tax_rate": 20,
    "tax_inclusive": true,
    "line_items": [
      {"sku":"CONCERTO-WAL-OO","description":"Concerto in walnut","quantity":1,"unit_price":"2850.00"}
    ]
  }'

The customer is upserted by email, the visitor is stitched, attribution is stamped, and the response includes public_url — redirect the shopper there or email it. The quote appears in the app tagged External so the team can see it came from the website rather than a till.

Converting to an order

curl -X POST https://app.stitchwork.io/api/v1/quotes/7/convert-to-order -b cookies.txt

Only from accepted or paid — anything else returns 409. The new order links back through orders.quote_id, and the conversion is idempotent: clicking twice finds the existing order rather than creating a second one.

See Orders for what happens next.

Quote events

Every state change and line change emits a webhook:

quote.created      quote.line_added
quote.updated      quote.line_updated
quote.sent         quote.line_removed
quote.accepted     quote.deleted
quote.declined
quote.cancelled
quote.paid

data.quote is the same shape the JSON API returns for a quote. See Webhooks.