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
draft. Acceptance and decline are only possible from
sent; payment from sent or accepted.
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.
| Field | Default | Notes |
|---|---|---|
channel_id | — | Required. |
customer_id | — | Required. |
currency | GBP | 3-letter ISO code. |
discount_type | none | amount or percent. Anything else is treated as no discount. |
discount_value | null | Money for amount, 0–100 for percent. |
tax_rate | 0 | A percentage, e.g. 20. |
tax_inclusive | false | See Totals and tax. |
valid_until | +30 days | Any parseable date. |
notes_customer | null | Shown on the public quote page. |
notes_internal | null | Never 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.
| Field | Notes |
|---|---|
description | What the customer reads. Blank becomes "Line item". |
quantity | Integer, minimum 1. |
unit_price | Money as a string. Non-numeric becomes 0.00. |
sku | Optional but recommended — it is what carries through to fulfilment. |
product_variant_id | Optional. 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:
| Mode | tax_amount | total | Use when |
|---|---|---|---|
tax_inclusive: falsedefault |
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:
notes_internalcreated_by_user_id,created_by_name,created_by_emailstripe_payment_intent_id,stripe_charge_id,stripe_connect_account_id
What the customer can do:
| Action | Endpoint | Allowed from |
|---|---|---|
| Accept | POST /quotes/public/{token}/accept | sent |
| Decline | POST /quotes/public/{token}/decline | sent — optional reason |
| Pay by card | POST /quotes/public/{token}/pay | sent or accepted, Stripe connected |
| Share browsing | The in-store handoff | Any state, if a join URL is configured |
Acting on a quote in the wrong state returns 409 with a plain-English reason.
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:
- Connect your Stripe account — the one you already have. You sign in on Stripe's own screen and choose the account; your payouts, history and card readers stay where they are.
- Create a Stripe account — if you don't have one, a new one is made in your name and Stripe walks you through setting it up.
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}
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:
| Token | Also accepts | Value |
|---|---|---|
{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.