Stitchwork Docs

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.

FieldDefaultNotes
channel_id—Required.
customer_id—Required.
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_token—Stitches 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.

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

This moves the quote to sent, stamps sent_at, settles any deposit, and emails the customer a link to their public quote page. A client kept by their phone number alone can be sent a quote too: it is marked sent, nothing is emailed, and when they can be texted the response carries text_draft — the link in a message their composer opens with, for a person to send.

The email goes the way every message to a customer does: from Stitchwork's sending address with your confirmed reply-to on it, so "can we change the fabric?" reaches you; not at all to an address that has bounced, complained or unsubscribed; and onto the customer's record with every other email, so "what have we already sent them?" includes the most important one. The quote is sent either way — its link and QR code work whether or not an email went — and the response says which:

{"data":{"quote":{…,"status":"sent"},
         "emailed":false,
         "email_note":"Not emailed: email to this address has bounced. Share the link or the QR code instead.",
         "text_draft":{"customer_id":42,"channel":"sms","body":"Hi Anna, here is your quote from Ashcroft & Vane: https://…/q/…"}}}

The app shows that note to whoever pressed Send, so nobody assumes the customer has it.

Hearing back

When a customer accepts or declines on the quote page, pays a deposit or pays in full, whoever raised the quote and whoever looks after the client are emailed — once each, with the reason when a customer gave one for declining, and a link to the quote. A considered sale often turns on a Saturday evening; the associate hears about it then, not on Monday.

Timeline notes

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 browsingThe in-store handoffAny 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. Start it from Settings → Payments, either way round:

Owner only. Once Stripe says the account can take charges (charges_enabled), the Pay button appears on public quote pages and card readers can take payments at the counter. Check with Stripe on the same card asks Stripe how the account stands straight away, and Disconnect lets go of it; you can also remove Stitchwork from Settings → Connected apps in your Stripe dashboard, and Stitchwork notices. Workspaces that connected before 2026-10 keep the account Stitchwork set up for them, which carries on working.

Paying creates a Stripe PaymentIntent as a direct charge on your own Stripe account — the money is yours from the moment it settles, and it never passes through us. Stripe's own card fees are charged there, as on any other payment you take, and refunds and chargebacks are handled in your Stripe dashboard.

Stitchwork's fee

Stitchwork takes a small application fee on each payment, on top of Stripe's own fees: 0.5% on Independent and 0.3% on Collection (Group is agreed), capped at £15 a payment — on a deposit and again on the balance, online and at the counter alike. On a £2,000 deposit taken online that is £10 to us beside about £30 to Stripe. Payments in currencies other than pounds, euros and dollars carry no fee. If you refund a payment, our fee goes back to you with it — all of it on a full refund, the same share on a part refund — without you asking. Settings → Payments shows your rate.

Stripe's webhook then records what was paid, by what Stripe actually took: short of the total the quote holds a deposit (deposit_paid, with the amount), at the total it is paid. Each payment is recorded once however often Stripe delivers the event, and only from the Stripe account your workspace connected; a second payment that raced the first — the customer paid again before the page caught up — is money and is recorded as money, and anything over the total is put on the quote's activity and emailed to the people looking after the sale, to refund. A failed payment lands as a note on the quote's timeline rather than changing its status. A refund made in Stripe is noted there too, so the people chasing the sale know; record it on the order if the sale has changed.

Taking it on a card reader

In the showroom, take the deposit or the balance on a Stripe card reader at the counter rather than sending the customer a link. The associate presses Take deposit on reader on the quote (Take payment on reader once a deposit is in), chooses how much — the deposit, or the whole amount if the customer would rather pay in full today — and which reader; the customer taps, inserts or swipes their card; and the quote is marked paid the moment Stripe approves it, exactly as an online payment is. A declined card says why and can be tried again; Cancel payment stops the reader.

Readers are the Stripe Reader S700 and the BBPOS WisePOS E, ordered from Stripe on your own account (Payments → Terminal → Hardware orders in your Stripe dashboard). Set them up in Settings → Payments → Card readers (owner or admin): match each showroom to a Stripe location — one you already have, or a new one made from the showroom's address — then add each reader with a name the team will know it by and the registration code it shows on its screen. Anyone who works at a showroom can take payments on its readers. Stitchwork's fee is the same as online.

Tap to Pay on a phone needs an app on the phone rather than a web page, so it is not offered yet. If your shop runs Shopify POS, its own card readers already take payment at the till; the readers here are for quotes, and for showrooms without a till.

GET  /api/v1/quotes/{id}/readers                        the showroom's readers, and what can be taken
POST /api/v1/quotes/{id}/reader-payment                 {"reader_id": "tmr_…", "kind": "deposit", "attempt_id": "…"}
GET  /api/v1/quotes/{id}/reader-payment/{pi}            waiting | approved | declined | canceled
POST /api/v1/quotes/{id}/reader-payment/{pi}/cancel

attempt_id is made by the device once per press of Send. The same id sent again — a double tap, or a retry after the Wi-Fi dropped — is the same payment, never a second charge.

Once a quote has been marked as ordered, the order is what is owed. The quote's Pay button stays only while that order still owes exactly what the quote does — a deposit taken online and the balance still to come — and the balance paid there is added to the order too. An order recorded as paid in the shop, or changed since, is the shop's to collect, so the customer is not offered the card a second time.

Deposits

A sofa, a kitchen or a commissioned ring is usually secured with a deposit rather than paid for up front. Set the shop's standing deposit once in Settings → Payments → Deposits — a percentage of the total (up to 90%) or a fixed amount — and any quote can ask for something different from its own Settings tab: its own percentage or amount, or no deposit at all.

The figure is worked out from the total the customer is looking at — tax in, discount off — rounded to the penny, and frozen on the quote as it is sent. A policy changed afterwards never moves a deposit a customer has already been asked for, and nothing moves once money has been taken. The quote email, the public page and the quote's own page all say it beside the total — "A deposit of £900.00 secures it, with £3,600.00 to follow" — including when you take payment in person. With payments connected, the customer's Pay button takes the deposit first and the balance later; the balance is then chased under Awaiting balance.

PUT /api/v1/settings/deposits      {"enabled": true, "type": "percent", "value": 20}
PUT /api/v1/quotes/{id}/deposit     {"deposit": "default" | "none" | "percent" | "fixed", "value": 900}
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.

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.

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

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.