> ## Documentation Index
> Fetch the complete documentation index at: https://docs.custral.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The Quote object

> Draft a quote for a customer, send them to its hosted page, and hear back when they pay.

A **quote** is an offer to one customer: products, prices, a discount and how
they pay. The customer accepts it on a hosted page (the quote's `url`), and
Custral starts billing them on your workspace's Stripe account.

Your own app uses these endpoints to sell. A typical **Upgrade** button drafts a
quote, sends the customer to its `url`, and learns the outcome by webhook.

<ResponseExample>
  ```json The Quote object theme={null}
  {
    "id": "quo_3Ab9xK2mQ7",
    "number": "Q-0007",
    "status": "sent",
    "customer": {"recordId": "rec_5Bc0xJ1kP4", "name": "Kestrel Logistics"},
    "url": "https://app.custral.com/quotes/qt_example_token",
    "contactName": "Jane Doe",
    "contactEmail": "jane@example.com",
    "currency": "usd",
    "collectionMethod": "autopay",
    "paymentTermsDays": 30,
    "trialDays": 0,
    "startDate": null,
    "expiresAt": "2026-10-31T23:59:59.000Z",
    "sentAt": "2026-09-30T10:00:00.000Z",
    "acceptedAt": null,
    "acceptedName": null,
    "createdAt": "2026-09-30T09:59:58.000Z",
    "lineItems": [
      {
        "productRecordId": "rec_2Aa1xK9mQ3",
        "name": "Pro plan",
        "quantity": 3,
        "unitAmount": 4950,
        "interval": "month"
      }
    ],
    "discount": {"type": "percent", "value": 20, "duration": "repeating", "months": 3},
    "paymentSchedule": [],
    "billingError": null,
    "totals": {
      "recurring": [
        {
          "interval": "month",
          "listAmount": 14850,
          "phases": [
            {"fromPeriod": 1, "toPeriod": 3, "amount": 11880},
            {"fromPeriod": 4, "toPeriod": null, "amount": 14850}
          ]
        }
      ],
      "oneTimeListTotal": 0,
      "oneTimeTotal": 0
    }
  }
  ```
</ResponseExample>

<Note>
  **Money in, money out.** Request prices are in **major** units: `49.5` is
  \$49.50. Every amount a response returns (`unitAmount`, an `amount`
  discount's `value`, invoice `total`, subscription `mrr`) is in **minor**
  units (cents), the way Stripe returns them.
</Note>

## Attributes

<ResponseField name="id" type="string">
  Unique identifier, prefixed `quo_`. Pass it to the other quote endpoints.
</ResponseField>

<ResponseField name="number" type="string">
  The label a person reads, such as `Q-0007`.
</ResponseField>

<ResponseField name="status" type="string">
  `draft`, `sent`, `viewed`, `accepted`, `declined`, `expired` or `void`.
</ResponseField>

<ResponseField name="customer" type="object">
  The customer record: `recordId`, and `name` (`null` when the record has none).
</ResponseField>

<ResponseField name="url" type="string">
  The hosted page where the customer reviews and accepts. It opens once the
  quote is **sent**. A draft's link does not.
</ResponseField>

<ResponseField name="collectionMethod" type="string">
  `autopay` (the customer saves a card or bank account when accepting, and is
  charged each cycle) or `invoice` (each bill is emailed with a pay link).
</ResponseField>

<ResponseField name="expiresAt" type="string | null">
  ISO 8601 timestamp: the end of the last day the quote can be accepted, UTC.
</ResponseField>

<ResponseField name="lineItems" type="object[]">
  One per product: `productRecordId`, `name`, `description`, `quantity`,
  `unitAmount` (minor units), `interval` (`one_time`, `month` or `year`), and
  `usageEvent` on a metered line, where `unitAmount` is per unit.
</ResponseField>

<ResponseField name="discount" type="object | null">
  The whole-quote discount: `type` (`percent` or `amount`), `value`,
  `duration` (`once`, `repeating` or `forever`) and `months`.
</ResponseField>

<ResponseField name="paymentSchedule" type="object[]">
  How the one-time total is split: each instalment's `label`, `due`
  (`on_acceptance` or a date), `type`, `value` and, once invoiced, `billedAt`.
</ResponseField>

<ResponseField name="billingError" type="string | null">
  Why billing could not start after the customer accepted, if it could not.
</ResponseField>

<ResponseField name="totals" type="object">
  What the quote costs, in minor units. `recurring` has one entry per billing
  interval: its `listAmount` per period before discounts, and `phases`: what
  each run of periods bills after discounts (`toPeriod: null` means from then
  on). `oneTimeTotal` is the one-time lines after discounts.
</ResponseField>

`contactName`, `contactEmail`, `currency`, `paymentTermsDays`, `trialDays`,
`billingEvery`, `billingAnchorDay` (null when bills run from acceptance), `requireSignature`,
`contractTemplateId` (null for the workspace's default), `startDate`, `sentAt`, `acceptedAt`, `acceptedName` (the name the customer
signed with) and `createdAt` are what they say.

## Endpoints

| Endpoint | Scope | Does |
| - | - | - |
| `POST /v1/quotes` | `quotes:write` | Drafts a quote |
| `GET /v1/quotes` | `billing:read` | Lists quotes, newest first |
| `GET /v1/quotes/{id}` | `billing:read` | Reads one quote |
| `PATCH /v1/quotes/{id}` | `quotes:write` | Edits a draft |
| `POST /v1/quotes/{id}/send` | `quotes:write` | Marks a draft sent, so its `url` opens |
| `POST /v1/quotes/{id}/void` | `quotes:write` | Withdraws an undecided quote |

No endpoint charges a card, bills an invoice, or changes a live subscription.
The customer does that by accepting.

## Naming the customer

`customer` is either **your app's own customer id** or a Custral record id
(`rec_…`).

Your own id works once the workspace has picked a **customer key** in
**Settings → Payments**: the property on the Customers object that holds your
app's id (an `Account ID`, say). Pass `acct_123` and Custral finds the customer
whose key is `acct_123`.

If no customer matches, the call is refused with `customer_not_found`. On
`POST /v1/quotes`, pass `createCustomer` to create one instead. It is created
with your id as its key, so the next call finds it.

## Create a quote

`POST /v1/quotes`

<ParamField body="customer" type="string" required>
  Your app's customer id, or a record id. A record of an object quotes start
  from (a deal, set in Settings → Payments → Who pays) works too: the quote
  bills the customer it links to and shows on both records.
</ParamField>

<ParamField body="createCustomer" type="object">
  `{name, email?}`. Creates the customer when `customer` matches none. Needs a
  customer key set in Settings → Payments.
</ParamField>

<ParamField body="products" type="object[]" required>
  1 to 100 products. Each is `{product, quantity?, unitPrice?}`: `product` is a
  catalog product's record id or its exact name, `quantity` defaults to 1, and
  `unitPrice` is a custom price per unit in **major** units (the catalog price
  when absent). A custom price never changes the catalog.
</ParamField>

<ParamField body="discount" type="object">
  `{type, value, duration?, months?}`. `type` is `percent` (`value: 20` for
  20%, at most 100) or `amount` (`value` in **major** units). `duration`
  defaults to `once`; `repeating` needs `months` (1 to 120).
</ParamField>

<ParamField body="collectionMethod" type="string">
  `autopay` or `invoice`. Defaults to the workspace's setting.
</ParamField>

<ParamField body="contactName" type="string">
  Who the quote is addressed to.
</ParamField>

<ParamField body="contactEmail" type="string">
  Their email. Custral does not email it when you send the quote.
</ParamField>

<ParamField body="billingEvery" type="number">
  Bill the recurring products every this many months (or years, for yearly
  products): `3` is quarterly. Each bill charges that many periods' price.
  1 to 12; yearly products at most 3. Defaults to 1.
</ParamField>

<ParamField body="billingAnchorDay" type="number">
  The day of the month bills land on, 1 to 31 (a shorter month bills on its last
  day), with the first bill prorated. `0` bills from the day the customer
  accepts, which is the default.
</ParamField>

<ParamField body="requireSignature" type="boolean">
  Ask the customer to sign a contract on the quote page before accepting.
</ParamField>

<ParamField body="contractTemplate" type="string">
  Which contract they sign: a template's `id` or exact `name` from
  [`GET /v1/contract-templates`](/dev/api-reference/contracts#list-contract-templates).
  Empty or `default` uses the workspace's default.
</ParamField>

<ParamField body="expiresAt" type="string">
  The last day the quote can be accepted, `YYYY-MM-DD`.
</ParamField>

<ParamField body="returnUrl" type="string">
  Where the customer lands after accepting, with `?quote=<number>&status=accepted`
  added to it. Must be `https` (`http` only for `localhost`), and its host must be
  listed in **Settings → Payments → allowed return domains**, exactly.
</ParamField>

<ParamField body="send" type="boolean">
  Also mark the quote sent, so the returned `url` opens right away. Saves the
  separate `send` call when the customer is about to be redirected.
</ParamField>

```ts theme={null}
const quote = await custral.quotes.create({
  customer: "acct_123",
  products: [{product: "Pro plan", quantity: 3}],
  discount: {type: "percent", value: 20, duration: "repeating", months: 3},
  expiresAt: "2026-10-31",
  returnUrl: "https://app.example.com/billing",
});
```

A new quote is a **draft** unless you pass `send: true`. Send it before giving the customer its `url`.

## The Upgrade button

The whole flow, from your server:

```ts theme={null}
import {Custral} from "@custral/sdk";

const custral = new Custral({apiKey: process.env.CUSTRAL_API_KEY!});

// 1. Your Upgrade button calls this.
app.post("/upgrade", async (req, res) => {
  // 2. `send: true` opens its link in the same call. Sending emails nobody.
  const quote = await custral.quotes.create({
    customer: req.user.accountId, // e.g. "acct_123"
    createCustomer: {name: req.user.companyName, email: req.user.email},
    products: [{product: "Pro plan", quantity: req.body.seats}],
    returnUrl: "https://app.example.com/billing",
    send: true,
  });

  // 3. Send the customer to the hosted page.
  res.redirect(303, quote.url);
});

// 4. They accept (saving a card first on autopay); the page then offers Continue to returnUrl.
// 5. Your app hears the outcome by webhook.
custral.on("subscription.started", async (event) => {
  // event.data: {recordId, plan, status, mrr, currency, interval, …}
});
custral.on("payment.succeeded", async (event) => {
  // event.data: {recordId, invoiceNumber, amountPaid, currency, …}
});

app.post("/webhooks/custral", custral.webhooks.express());
```

1. Your app creates the quote with a `returnUrl`.
2. `send` marks it sent, so its `url` opens.
3. You redirect the customer to `url`.
4. The customer accepts. With `autopay` they save a card or bank account on
   Stripe first. The quote page then shows a Continue button that opens your
   `returnUrl` with `?quote=<number>&status=accepted`.
5. Custral starts the subscription and bills any one-time total. Your app
   hears `subscription.started` and `payment.succeeded` by
   [webhook](/dev/webhooks/overview), and `quote.accepted` as well.

Act on the webhook, and use the return to `returnUrl` only to show a page: a
customer can close the tab without pressing Continue. Webhook delivery is
at-least-once, so make the listeners idempotent.

## List quotes

`GET /v1/quotes`

<ParamField query="customer" type="string">
  One customer's quotes: your app's id or a record id. A deal's record id lists
  the quotes made on it. The whole workspace's when absent.
</ParamField>

<ParamField query="status" type="string">
  Only quotes in this status.
</ParamField>

```ts theme={null}
const open = await custral.quotes.list({customer: "acct_123", status: "sent"});
```

Returns an array of quotes, newest first.

## Retrieve a quote

`GET /v1/quotes/{id}`

```ts theme={null}
const quote = await custral.quotes.retrieve("quo_3Ab9xK2mQ7");
```

## Update a draft

`PATCH /v1/quotes/{id}`

Takes the same fields as create, except `customer`, `createCustomer` and
`returnUrl`. A field you leave out keeps its value. `products` replaces every
line.

<ParamField body="clearDiscount" type="boolean">
  `true` removes the quote's discount.
</ParamField>

Only a draft can be edited. Once sent, its link must not change under the
customer: void it and create another.

```ts theme={null}
await custral.quotes.update("quo_3Ab9xK2mQ7", {
  products: [{product: "Pro plan", quantity: 5, unitPrice: 45}],
  clearDiscount: true,
});
```

## Send a quote

`POST /v1/quotes/{id}/send`

Marks a draft sent, so its `url` opens. **It emails nobody**: you give the
customer the link yourself, by redirect or in your own email.

Sending a quote that is already `sent` or `viewed` returns it unchanged, so a
retry is safe. An `autopay` quote needs the workspace's Stripe account
connected.

```ts theme={null}
const quote = await custral.quotes.send("quo_3Ab9xK2mQ7");
```

## Void a quote

`POST /v1/quotes/{id}/void`

Withdraws a quote that is `draft`, `sent`, `viewed` or `expired`. Its link stops
working. An accepted or declined quote cannot be voided.

```ts theme={null}
await custral.quotes.void("quo_3Ab9xK2mQ7");
```

## Errors

| Code | When |
| - | - |
| `insufficient_scope` | The key lacks `quotes:write` (writes) or `billing:read` (reads). |
| `payments_not_set_up` | The workspace has not set up payments in Settings → Payments. |
| `customer_not_found` | `customer` matches no customer, and no `createCustomer` was sent. |
| `customer_key_not_set` | `createCustomer` was sent, but the workspace has no customer key, so the new customer could never be found by your id. |
| `quote_product_not_found` | A `product` is not in the catalog, or more than one product has that name. Pass the record id. |
| `invalid_discount` | The discount is not valid: a percent over 100, or `repeating` without `months`. |
| `invalid_expiry` | `expiresAt` is not a real `YYYY-MM-DD` date. |
| `billing_every_too_long` | Yearly products billed more than 3 years at a time. |
| `contract_template_not_found` | `contractTemplate` names no live template in the workspace. |
| `contract_template_ambiguous` | Two templates share that name. Pass the `id`. |
| `return_url_invalid` | `returnUrl` is not an `https` URL, or carries a username or password. |
| `return_url_not_allowed` | `returnUrl`'s host is not in Settings → Payments → allowed return domains. |
| `quote_not_found` | No quote with that id in this workspace. |
| `insufficient_permissions` | The member who created the key cannot see (for reads) or edit (for writes) the quote's customer. |
| `quote_not_draft` | `PATCH` on a quote that has been sent. |
| `quote_not_sendable` | `send` on a quote that is accepted, declined, expired or void. |
| `quote_empty` | `send` on a quote with no lines. |
| `quote_expired` | `send` on a draft whose `expiresAt` has passed. Set a later one first. |
| `stripe_not_connected` | `send` on an `autopay` quote before the workspace has connected Stripe. |
| `quote_not_voidable` | `void` on a quote that was accepted or declined. |

A refused `returnUrl` is checked before anything is written, so no quote is
left behind. See [Errors](/dev/errors/overview) for the HTTP status each code
maps to.

## Scopes

| Scope | Allows |
| - | - |
| `billing:read` | Listing and reading quotes, and every [billing](/dev/api-reference/billing) read |
| `quotes:write` | Creating, editing, sending and voiding quotes |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.