> ## 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.

# Quotes and billing

> A customer's decision on a quote, and payments, overdue invoices and subscription changes from the workspace's Stripe account.

## Overview

Eleven events cover [quote-to-cash](/revenue/overview). The four quote events fire when a customer acts on a quote's hosted page, or when the quote expires. The seven billing events come from the workspace's own Stripe account as Stripe reports them, plus a daily check for overdue invoices.

Every event carries `recordId`: the customer record the quote or invoice belongs to. A billing event Custral can't link to one of the workspace's customer records is not delivered.

| Event | Fires when |
| - | - |
| `quote.viewed` | The customer opens a sent quote for the first time. |
| `quote.accepted` | The customer accepts. On an autopay quote, only once their payment method is saved. |
| `quote.declined` | The customer declines, with their reason if they gave one. |
| `quote.expired` | A sent or viewed quote passes its expiry date. |
| `payment.succeeded` | An invoice is paid, by an autopay charge or through its pay link. A \$0 invoice (a trial) doesn't count. |
| `payment.failed` | An autopay charge fails. The invoice stays open. |
| `invoice.overdue` | An open invoice passes its due date. Checked daily, delivered once per invoice. |
| `subscription.started` | A subscription is created, for example when an accepted quote starts billing. |
| `subscription.updated` | A subscription's items, quantity, plan, discount, status, cancellation, collection method or trial end changes. A plain renewal is not delivered. |
| `subscription.canceled` | A subscription ends. |
| `trial.ending` | Three days before a subscription's free trial ends. |
| `contract.renewing` | Once per term, when a contract comes within its notice days of its end. `renews` says whether it renews or ends. |

A quote being sent or voided starts [workflows](/revenue/automations) but delivers no webhook.

## Quote events

```json theme={null}
{
  "id": "whd_7Kp2rT9xC",
  "event": "quote.accepted",
  "createdAt": "2026-09-30T10:04:12.381Z",
  "data": {
    "quoteId": "quo_3Ab9xK2mQ7",
    "quoteNumber": "Q-0007",
    "status": "accepted",
    "recordId": "rec_5Bc0xJ1kP4",
    "url": "https://app.custral.com/quotes/qt_example_token",
    "contactName": "Jane Doe",
    "contactEmail": "jane@example.com"
  }
}
```

<ResponseField name="quoteId" type="string" required>
  The quote. Pass it to [`GET /v1/quotes/{id}`](/dev/api-reference/quotes#retrieve-a-quote).
</ResponseField>

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

<ResponseField name="status" type="string">
  `viewed`, `accepted`, `declined` or `expired`, matching the event.
</ResponseField>

<ResponseField name="recordId" type="string" required>
  The customer record the quote is for.
</ResponseField>

<ResponseField name="url" type="string">
  The quote's hosted page.
</ResponseField>

<ResponseField name="contactName" type="string">
  Who the quote is addressed to.
</ResponseField>

<ResponseField name="contactEmail" type="string">
  Their email.
</ResponseField>

<ResponseField name="declineReason" type="string">
  On `quote.declined`, what the customer wrote, if anything.
</ResponseField>

## Invoice events

`payment.succeeded`, `payment.failed` and `invoice.overdue` share one shape. Amounts are in **minor** units (cents).

```json theme={null}
{
  "id": "whd_2Nq8sV4yD",
  "event": "invoice.overdue",
  "createdAt": "2026-10-06T06:20:41.902Z",
  "data": {
    "recordId": "rec_5Bc0xJ1kP4",
    "invoiceNumber": "INV-0131",
    "amount": 108000,
    "amountPaid": 0,
    "amountDue": 108000,
    "currency": "usd",
    "payUrl": "https://invoice.stripe.com/i/example",
    "customerName": "Kestrel Logistics",
    "customerEmail": "billing@kestrel.example",
    "dueDate": "2026-10-05T00:00:00.000Z",
    "daysOverdue": 1
  }
}
```

| Field | Means |
| - | - |
| `recordId` | The customer record. |
| `invoiceNumber` | Stripe's invoice number. |
| `amount` | The invoice total. |
| `amountPaid` | What has been paid on it. |
| `amountDue` | What is still owed. |
| `currency` | Lowercase ISO code, such as `usd`. |
| `payUrl` | Stripe's hosted page where the customer pays the invoice. |
| `customerName`, `customerEmail` | Who Stripe billed. |
| `dueDate` | ISO 8601, or `null` when the invoice has no due date. |
| `daysOverdue` | `invoice.overdue` only. Whole days past the due date. |
| `attemptCount` | `payment.failed` only. How many times Stripe has tried to charge it. |

## Subscription events

`subscription.started`, `subscription.updated`, `subscription.canceled` and `trial.ending` share one shape.

```json theme={null}
{
  "id": "whd_5Rt3wX8zE",
  "event": "subscription.started",
  "createdAt": "2026-09-30T10:04:15.027Z",
  "data": {
    "recordId": "rec_5Bc0xJ1kP4",
    "plan": "Pro plan",
    "status": "active",
    "seats": 3,
    "mrr": 11880,
    "currency": "usd",
    "interval": "month",
    "trialEnd": null,
    "cancelAtPeriodEnd": false,
    "cancelReason": null,
    "cancelComment": null
  }
}
```

| Field | Means |
| - | - |
| `recordId` | The customer record. |
| `plan` | The subscription's description, or its first price's nickname. |
| `status` | Stripe's subscription status, such as `active`, `trialing` or `canceled`. |
| `seats` | The quantities of its items, added up. |
| `mrr` | Monthly recurring revenue in minor units, after the discounts in force. `0` while it isn't billing (a trial, say). |
| `currency` | Lowercase ISO code. |
| `interval` | The billing interval, such as `month` or `year`. |
| `trialEnd` | ISO 8601 when it has a free trial, else `null`. |
| `cancelAtPeriodEnd` | `true` when it is set to end at the close of the current period. |
| `cancelReason` | Why the customer cancelled, when they said: `too_expensive`, `missing_features`, `switched_service`, `unused` or `other`. Else `null`. |
| `cancelComment` | What the customer wrote when they cancelled, else `null`. |
| `changedFields` | `subscription.updated` only. The Stripe fields that changed, such as `["quantity"]`. |

A field Stripe left empty (a customer name, say) is `null` rather than missing. `daysOverdue`, `attemptCount` and `changedFields` appear only on the events named above.

## Listening with the SDK

```ts theme={null}
custral.on("quote.accepted", async (event) => {
  await markUpgradePending(event.data.recordId, event.data.quoteNumber);
});

custral.on("payment.failed", async (event) => {
  await warnAccount(event.data.recordId, event.data.payUrl);
});
```

Delivery is at-least-once, so make listeners idempotent. A billing event can arrive before or after the quote event that led to it; don't depend on the order. See [Bill from your product](/dev/guides/bill-from-your-product) for the full flow.

## Related documentation

<CardGroup cols={2}>
  <Card title="The Quote object" icon="file-signature" href="/dev/api-reference/quotes">
    Create, send and read quotes from your own code.
  </Card>

  <Card title="Billing" icon="credit-card" href="/dev/api-reference/billing">
    Read a customer's subscriptions and invoices back.
  </Card>
</CardGroup>


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