Quotes

Quotations, with a full read/write surface mirroring invoices — create, replace, and delete a draft quote, alongside line items and an embedded client summary. Reads carry the quotes:read ability, writes the quotes:write ability.

Changed: quotes used to be read-only, riding the invoices:read ability. As of this release they have their own quotes:read/quotes:write abilities instead — a token issued with only invoices:read no longer reads quotes, and invoices:write never wrote them and still doesn't. If you have an existing integration that reads quotes, reissue its token with quotes:read. This is a deliberate pre-launch breaking change (see Tokens).

Operations

Method & path Ability
GET /api/v1/quotes quotes:read
GET /api/v1/quotes/{quote} quotes:read
GET /api/v1/quotes/{quote}/pdf quotes:read
POST /api/v1/quotes quotes:write
PATCH /api/v1/quotes/{quote} quotes:write
DELETE /api/v1/quotes/{quote} quotes:write
POST /api/v1/quotes/{quote}/send quotes:write
POST /api/v1/quotes/{quote}/mark-sent quotes:write
POST /api/v1/quotes/{quote}/mark-accepted quotes:write
POST /api/v1/quotes/{quote}/mark-declined quotes:write
POST /api/v1/quotes/{quote}/convert-to-invoice quotes:write

Get a quote

curl https://billey.nl/api/v1/quotes/15 \
  -H "Authorization: Bearer $TOKEN"
{
  "data": {
    "id": 15,
    "quote_number": "Q-2026-0042",
    "status": "sent",
    "currency": "EUR",
    "quote_date": "2026-07-10",
    "valid_until": "2026-08-10",
    "subtotal": "4750.00",
    "btw_total": "997.50",
    "total": "5747.50",
    "converted_invoice_id": null,
    "client": { "id": 7, "name": "Example Client B.V." },
    "line_items": [
      {
        "id": 1,
        "description": "Project phase 1",
        "quantity": "1.00",
        "unit_price": "4750.00",
        "btw_percentage": "21.00",
        "btw_amount": "997.50",
        "line_total": "4750.00"
      }
    ]
  }
}

status is one of draft, sent, accepted, declined. When a quote has been turned into an invoice, converted_invoice_id points at it. The OpenAPI spec lists every field.

Download the PDF

GET /api/v1/quotes/{quote}/pdf renders and returns the same PDF the web app's "Download PDF" button produces (not JSON). This route carries its own, tighter rate limit — see Rate limits.

curl -L https://billey.nl/api/v1/quotes/15/pdf \
  -H "Authorization: Bearer $TOKEN" \
  -o quote.pdf

Create a quote

POST /api/v1/quotes requires an Idempotency-Key header (see Idempotency) and creates a draft. quote_number is optional — when omitted the next number is allocated automatically, exactly like an invoice. items must contain at least one line.

Money and quantities on the API are strict, locale-free: plain dot-decimal strings or native numbers only ("103.31", not "103,31") — see Money & decimals for why.

curl -X POST https://billey.nl/api/v1/quotes \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: quote-2026-08-03-001" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": 7,
    "currency": "EUR",
    "quote_date": "2026-08-03",
    "valid_until": "2026-09-02",
    "items": [
      { "description": "Project phase 1", "quantity": "1", "unit_price": "4750.00", "btw_percentage": 21 }
    ]
  }'

A fresh create returns 201; replaying the same Idempotency-Key returns 200 with the original quote unchanged, no second row created.

Line item shape

Each entry in items requires:

Field Type Notes
description string Max 255 characters.
quantity string/number Strict decimal, minimum 0.01.
unit_price string/number Strict money, minimum 0.
btw_percentage number Must be one of the book's configured BTW rates.

Unlike an invoice line item, a quote line item has no icp_category — the column doesn't exist on quotes, since ICP/rubriek-3b reporting only applies to issued invoices.

Tax treatment and KOR

tax_treatment (or the legacy reverse_charge_applied boolean) works exactly as it does on invoices — see Invoices's "Cross-border VAT" section. One deliberate difference from invoices: a quote's line btw_percentage is never adjusted for KOR. An invoice under a KOR-registered book has its rates zeroed automatically; a quote does not, because a quote is a proposal, not a KOR-eligible turnover document — the rate you send is the rate stored.

Replace a quote (PATCH)

PATCH /api/v1/quotes/{quote} is a full replace, not a partial merge — see PATCH behaviour. Resend every header field and the complete items array; an omitted notes becomes null.

quote_number cannot be changed via PATCH at all — unlike an invoice, there is no renumbering capability. A quote's number is fixed the moment it's created; any quote_number in the PATCH payload is silently ignored.

Only a draft quote can be edited — a PATCH on a sent, accepted, or declined quote returns 409 Conflict.

Delete a quote

DELETE /api/v1/quotes/{quote} hard-deletes the quote (Quote does not soft-delete, unlike Invoice/Client/Vendor). There is no status restriction — a quote in any status, including one already converted to an invoice, can be deleted; the converted invoice itself is untouched (its converted_invoice_id link on the deleted quote simply goes with it).

Lifecycle transitions

Move a quote through draft → sent → accepted or declined with these bodyless POSTs (no Idempotency-Key); an invalid transition returns 409:

  • send — email the quote to the client and mark it Sent (the client must have an email address, else 422).
  • mark-sent — mark Sent without emailing, for when you deliver the quote through your own channel.
  • mark-accepted — transition Sent → Accepted.
  • mark-declined — transition Sent → Declined.
curl -X POST https://billey.nl/api/v1/quotes/15/send \
  -H "Authorization: Bearer $TOKEN"

Convert a quote to an invoice

POST /api/v1/quotes/{quote}/convert-to-invoice creates a new draft invoice from the quote's client, currency, tax treatment, and line items — the same writer POST /api/v1/invoices uses — links it back onto the quote as converted_invoice_id, and sets the quote's status to accepted. As a create, it takes an Idempotency-Key.

curl -X POST https://billey.nl/api/v1/quotes/15/convert-to-invoice \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"

A quote converts once. Calling this again on an already-converted quote never creates a second invoice — it returns the existing one, with 200 instead of 201, even if you send a different Idempotency-Key than the original call. This is enforced with a row lock inside the write transaction, not just a stale read-then-write check, so it holds under genuinely concurrent requests as well as a simple double-click or retry.

That "existing one" is returned regardless of whether it still exists: an invoice created from a quote is an ordinary draft, and a draft invoice is deletable (DELETE /api/v1/invoices/{invoice}). If the resulting invoice is later deleted, a repeat convert-to-invoice call on that quote still returns 200 with the (now soft-deleted) invoice's data — converted_invoice_id isn't cleared by the invoice's deletion, and this endpoint never re-checks that the row it points at is still live. A GET /api/v1/invoices/{id} for that same id would 404. Don't treat a 200 here as proof the invoice is currently retrievable.

A quote with no line items returns 422 rather than converting an empty invoice.

Something inaccurate or missing? support@billey.nl