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

# Products, Line Items and Quotes API

> Keep a product library, put products on deals as line items, and send quotes your buyers accept (and pay) from a link.

A **product** is something you sell, with a unit price, a currency and an optional recurring interval. Put products on a deal as **line items** (quantity, discount, monthly or yearly billing) and the deal amount can follow their total. A **quote** copies a deal's line items, adds terms and an expiry, and is shared as a public page where the buyer accepts it, and pays when the quote carries a Stripe payment link from your own Stripe account.

The [MCP server](https://docs.orbitforms.ai/mcp/tools.md) exposes the same through `list_products`, `save_product`, `list_deal_line_items`, `update_deal_line_items`, `list_quotes`, `create_quote` and `update_quote`. Workflows add products with the **Add Product to Deal** step.

## Scopes

Products, line items and quotes are part of deals: reads need `deals:read`, writes need `deals:write`. Line items and quotes follow the deal's visibility: a credential that can't see a deal (its pipeline is restricted, or record access limits it to other owners) can't see its line items or quotes either.

## Products

| Method | Path | |
| - | - | - |
| `GET` | `/api/v1/products` | The library. Query: `search` (name or SKU), `archived` (`exclude` by default, `include`, `only`), `currency`, `page`, `per_page` |
| `POST` | `/api/v1/products` | `{ name, unit_price, sku?, description?, currency?, recurring_interval? }`. `currency` defaults to the workspace's deal currency; `recurring_interval` is `month`, `year` or `null` (one-time) |
| `GET` | `/api/v1/products/:id` | One product |
| `PATCH` | `/api/v1/products/:id` | Any field. `archived: true` archives it: it can no longer be added to deals, and the lines that already have it keep their copy. `archived: false` restores it |
| `POST` | `/api/v1/products/import` | `{ products: [...] }`, up to 1,000. A row whose `sku` matches a product that isn't archived updates it; other rows create products. Answers `{ created, updated, errors: [{ row, error }] }`; rows with errors are skipped, the rest are imported |

A SKU is unique among products that aren't archived (`409 duplicate_sku`). Unit prices take up to four decimals.

```json theme={null}
{
  "id": "5b6c7d8e-...",
  "name": "Pro plan",
  "sku": "PLAN-PRO",
  "description": "Up to 10 seats",
  "unit_price": 99,
  "currency": "USD",
  "recurring_interval": "month",
  "archived": false,
  "archived_at": null,
  "created_at": "2026-11-01T09:00:00Z",
  "updated_at": "2026-11-01T09:00:00Z"
}
```

## Line items

| Method | Path | |
| - | - | - |
| `GET` | `/api/v1/deals/:id/line-items` | `{ deal: { id, name, currency, amount, amount_from_line_items }, line_items, totals }` |
| `POST` | `/api/v1/deals/:id/line-items` | Add one line: `{ product_id }` copies the product's name, SKU, description, price and interval, or `{ name, unit_price }` for a one-off line. Optional: `quantity` (default 1), `discount_type` (`percent` or `amount`), `discount`, `recurring_interval`, `sku`, `description`, and any of the product's values to override |
| `PATCH` | `/api/v1/deals/:id/line-items` | Several changes at once: `{ add?: [...], update?: [{ id, ...fields }], remove?: [id], amount_from_line_items?: true \| false }`. Everything is checked before anything is written. Answers like `GET` |
| `PATCH` | `/api/v1/deals/:id/line-items/:itemId` | Change one line |
| `DELETE` | `/api/v1/deals/:id/line-items/:itemId` | Remove one line |

* A line is in its **deal's currency**; it has none of its own. Adding a product priced in another currency needs `unit_price` in the deal's currency (`400 currency_mismatch` otherwise). Changing a deal's currency doesn't convert its lines.
* `amount` is quantity × unit price less the discount, never below zero, rounded to cents. A percent discount is at most 100; an `amount` discount is off the whole line.
* `recurring_interval` (`month`, `year` or `null`) is the same vocabulary as the deal's own recurring revenue fields. A recurring line counts one period in `totals.total`; `totals.recurring` adds them up per interval and `totals.one_time` the rest.
* `amount_from_line_items: true` makes the deal amount the lines' total from then on: every change to the lines updates it (and sends `deal.updated`), and setting `amount` directly is overridden. The deal's `recurring_amount` and `recurring_interval` follow the recurring lines the same way: when they all bill on one interval the deal takes it and their sum; mixed monthly and yearly lines are converted to the deal's own interval (×12 or ÷12); with no recurring line, `recurring_amount` is `null`. `false` lets you set both by hand again.
* A deal has up to 200 lines. A won or lost deal in a pipeline that locks closed deals refuses line changes (`422 closed_deal_locked`) unless the credential's owner is a workspace admin.
* Archived products can't be added.

## Quotes

| Method | Path | |
| - | - | - |
| `GET` | `/api/v1/quotes` | Query: `deal_id`, `status` (`draft`, `open`, `accepted`, `expired`, `void`), `page`, `per_page` |
| `POST` | `/api/v1/quotes` | `{ deal_id, title?, terms?, expires_at?, include_payment_link?, publish? }` |
| `GET` | `/api/v1/quotes/:id` | One quote |
| `PATCH` | `/api/v1/quotes/:id` | A draft's `title`, `terms`, `expires_at`, `include_payment_link`; `refresh_items: true` copies the deal's line items again |
| `POST` | `/api/v1/quotes/:id/publish` | Share a draft |
| `POST` | `/api/v1/quotes/:id/void` | Void a shared or accepted quote |
| `DELETE` | `/api/v1/quotes/:id` | Delete a draft |

* **Merging deals** moves the merged deal's quotes to the deal that is kept, so their links keep working; line items stay on the merged deal.
* **Making a quote** copies the deal's line items, its primary contact and company as the buyer (`recipient`), and the workspace and the credential's owner as `sender`. Later changes to the deal don't change the quote. `title` defaults to the deal name; `terms` and `expires_at` default to the workspace's quote settings (Settings → Products). `expires_at` takes a date (`YYYY-MM-DD`, valid through the end of that day, UTC) or an ISO 8601 date-time, or `null` for no expiry.
* **Sharing** (`publish`, or `publish: true` on create) turns a draft into an `open` quote: `public_url` starts working and the quote can no longer change. A quote needs at least one line to be shared. With `include_payment_link`, a Stripe payment link is created in the workspace's own Stripe account at that moment (`payment_link_url`); one payment link carries up to 20 lines and can't mix monthly and yearly lines. When `publish: true` on create is refused (Stripe, say), the draft still exists: `details.quote_id` names it.
* **The public page** shows the quote's lines, totals, terms and expiry, and nothing else from your workspace. The buyer accepts with their name and email; the quote becomes `accepted` with `accepted_name`, `accepted_email` and `accepted_at`, and the page then shows **Pay now** when the quote has a payment link. An open quote past `expires_at` shows as expired (`display_status: "expired"`) and can't be accepted. Views are counted once an hour per viewer, so reloads don't inflate them (`view_count`, `first_viewed_at`, `last_viewed_at`), and the deal's owner is notified when the quote is accepted.
* **Voiding** makes the page say the quote is no longer available and switches its payment link off in Stripe. A quote whose deal is deleted reads as no longer available too, until the deal is restored.

```json theme={null}
{
  "id": "0c1d2e3f-...",
  "number": 1004,
  "label": "Q-1004",
  "deal_id": "9a1d3c4e-...",
  "title": "Acme rollout",
  "status": "open",
  "display_status": "open",
  "currency": "USD",
  "items": [
    { "line_item_id": "...", "product_id": "5b6c7d8e-...", "name": "Pro plan", "sku": "PLAN-PRO", "description": null, "quantity": 10, "unit_price": 99, "discount_type": "percent", "discount": 10, "recurring_interval": "month", "amount": 891 }
  ],
  "subtotal": 990,
  "discount_total": 99,
  "total": 891,
  "totals": { "subtotal": 990, "discount_total": 99, "total": 891, "one_time": 0, "recurring": { "month": 891 } },
  "terms": "Billed monthly. Cancel with 30 days' notice.",
  "expires_at": "2026-11-30T23:59:59.999Z",
  "recipient": { "name": "Jane Doe", "email": "jane@acme.com", "company": "Acme" },
  "sender": { "name": "Sam Rep", "email": "sam@example.com", "company": "Example Inc" },
  "include_payment_link": true,
  "payment_link_url": "https://buy.stripe.com/...",
  "public_url": "https://orbitforms.ai/q/...",
  "published_at": "2026-11-01T10:00:00Z",
  "first_viewed_at": null,
  "last_viewed_at": null,
  "view_count": 0,
  "accepted_at": null,
  "accepted_name": null,
  "accepted_email": null,
  "voided_at": null
}
```

### Stripe payment links

Payment links use **your own Stripe account**: payments go straight to it and Orbit never handles the money. A workspace admin connects it in Settings → Products with a Stripe **restricted key** that has Write access to Payment Links, Prices and Products and nothing else. The key is checked with Stripe, stored encrypted and never returned by any API. Secret keys (`sk_...`) are refused.

## Webhooks

`product.*`, `quote.*` and `deal.line_item_*` events are listed on the [events page](https://docs.orbitforms.ai/developers/webhooks/events.md), all behind `deals:read`.


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