Quotes
Quotes API
Endpoints for creating, listing, and managing professional quotes with line items, status tracking, PDF generation, and version history.
List Quotes
GET /api/quotes
Permission: quotes:read | Collaborators see only their own quotes; Owners/Admins see all.
Retrieve a paginated list of quotes for the current tenant.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| search | string | No | Search by quote number, client contact name, or company name (case-insensitive) |
| status | string | No | Filter by status: GENERATED, SENT, WON, LOST, ACCEPTED, REJECTED |
| page | number | No | Page number (default: 1) |
Response 200 OK:
{
"data": [
{
"id": "clxyz...",
"quoteNumber": 42,
"status": "SENT",
"subtotal": "15000.00",
"tax": "2400.00",
"shippingCost": "500.00",
"total": "17900.00",
"notes": "Entrega en 5 días hábiles",
"expiresAt": "2026-04-15T00:00:00.000Z",
"createdAt": "2026-03-31T10:00:00.000Z",
"client": {
"contactName": "María López",
"companyName": "Distribuidora del Norte S.A."
},
"user": {
"name": "Carlos Méndez"
},
"_count": {
"items": 3
}
}
],
"total": 87,
"page": 1,
"totalPages": 5
}Errors:
| Status | Error | When |
|---|---|---|
| 401 | "No autorizado" | Missing or invalid session |
| 403 | "Sin permisos" | Role lacks quotes:read |
cURL Example:
curl -X GET "https://cotizera.com/api/quotes?page=1&status=SENT&search=María" \
-H "Cookie: next-auth.session-token=YOUR_TOKEN"Create Quote
POST /api/quotes
Permission: quotes:create
Create a new quote with line items. Automatically calculates subtotals, tax (from tenant settings or default 16%), and total. Assigns a sequential quote number per tenant.
Request Body:
{
"clientId": "clxyz...",
"items": [
{
"productId": "clxyz...",
"productName": "Laptop HP ProBook 450",
"model": "450-G10",
"unitPrice": 18500,
"quantity": 2,
"discountPct": 5
}
],
"notes": "Incluye instalación y configuración",
"shippingCost": 350
}| Field | Type | Required | Description |
|---|---|---|---|
| clientId | string | Yes | Client ID (must belong to the same tenant and be active) |
| items | array | Yes | At least one line item |
| items[].productId | string | No | Reference to product catalog |
| items[].productName | string | Yes | Product display name |
| items[].model | string | No | Model or SKU |
| items[].unitPrice | number | Yes | Unit price (≥ 0) |
| items[].quantity | integer | Yes | Quantity (≥ 1) |
| items[].discountPct | number | No | Line discount percentage (0–100, default: 0) |
| notes | string | No | Free-text notes (max 300 characters) |
| shippingCost | number | No | Shipping cost (≥ 0, default: 0) |
Response 201 Created:
{
"id": "clxyz...",
"quoteNumber": 43,
"status": "GENERATED",
"subtotal": 35150,
"tax": 5624,
"shippingCost": 350,
"total": 41124,
"notes": "Incluye instalación y configuración",
"expiresAt": "2026-04-15T00:00:00.000Z",
"publicToken": "abc123...",
"createdAt": "2026-03-31T10:00:00.000Z",
"items": [...],
"client": { "id": "...", "contactName": "María López", ... }
}Errors:
| Status | Error | When |
|---|---|---|
| 400 | Zod validation message | Invalid input (e.g., empty items array) |
| 401 | "No autorizado" | Missing or invalid session |
| 403 | "Sin permisos" | Role lacks quotes:create |
| 404 | "Cliente no encontrado" | Client doesn't exist, belongs to another tenant, or is inactive |
Side Effects:
- Fires
quote.createdwebhook event - Triggers onboarding step
first_quotecompletion - Logs audit entry with action
create
Get Single Quote
GET /api/quotes/:id
Permission: quotes:read | Collaborators can only access their own quotes.
Retrieve a single quote with full line items, client details, and creator info.
Response 200 OK:
{
"id": "clxyz...",
"quoteNumber": 42,
"status": "SENT",
"subtotal": "15000.00",
"tax": "2400.00",
"total": "17900.00",
"items": [
{
"id": "...",
"productName": "Monitor Dell 27\"",
"model": "U2723QE",
"unitPrice": "7500.00",
"quantity": 2,
"discountPct": "0.00",
"subtotal": "15000.00",
"product": { "id": "...", "name": "Monitor Dell 27\"", "modelSku": "U2723QE" }
}
],
"client": { "id": "...", "contactName": "María López", "companyName": "...", "email": "...", "phone": "..." },
"user": { "name": "Carlos Méndez", "email": "carlos@example.com" }
}Errors:
| Status | Error | When |
|---|---|---|
| 401 | "No autorizado" | Missing or invalid session |
| 403 | "Sin permisos" | Role lacks quotes:read |
| 404 | "Cotización no encontrada" | Quote doesn't exist or access denied |
Update Quote Status
PATCH /api/quotes/:id/status
Permission: quotes:read_all (Owner/Admin only)
Update the status of a quote. Follows a strict state machine:
| From | Allowed Transitions |
|---|---|
GENERATED |
→ SENT |
SENT |
→ WON, LOST |
WON |
(terminal) |
LOST |
(terminal) |
ACCEPTED |
(terminal) |
REJECTED |
(terminal) |
Request Body:
{
"status": "SENT"
}| Field | Type | Required | Description |
|---|---|---|---|
| status | QuoteStatus | Yes | GENERATED, SENT, WON, LOST, ACCEPTED, REJECTED |
Response 200 OK: Returns the updated quote object.
Errors:
| Status | Error | When |
|---|---|---|
| 400 | "No se puede cambiar de GENERATED a WON" | Invalid state transition |
| 404 | "Cotización no encontrada" | Quote not found in tenant |
Side Effects:
- Fires
quote.status_changedwebhook event - Logs audit entry with action
status_change
Duplicate Quote
POST /api/quotes/:id/duplicate
Permission: quotes:create | Plan: PRO
Create a copy of an existing quote with a new quote number, fresh expiration date, and recalculated tax.
Request Body: None required.
Response 201 Created: Returns the new quote object with items and client.
Errors:
| Status | Error | When |
|---|---|---|
| 403 | "Función disponible en el plan Pro" | Tenant is on FREE plan |
| 404 | "Cotización no encontrada" | Original quote not found |
Share Quote via Email
POST /api/quotes/:id/share
Permission: quotes:read
Generates a PDF (if not already cached), uploads it to S3, and sends it via email to the client. Automatically transitions status from GENERATED → SENT.
Request Body: None required.
Response 200 OK:
{
"success": true
}Errors:
| Status | Error | When |
|---|---|---|
| 404 | "Cotización no encontrada" | Quote not found or access denied |
| 500 | "Error al enviar el correo" | Email delivery failed |
Generate / Stream PDF
GET /api/quotes/:id/pdf
Permission: quotes:read
Streams the PDF directly as a binary response. Generates it on-the-fly with current data, including digital signature if present. PRO tenants get custom branding (colors, logo, no watermark, bank info).
Response 200 OK:
- Content-Type:
application/pdf - Content-Disposition:
inline; filename="COT-0042.pdf"
POST /api/quotes/:id/pdf
Permission: quotes:read
Generates the PDF, uploads it to S3, and returns a presigned download URL. If already uploaded, returns a fresh presigned URL without regenerating.
Response 200 OK:
{
"pdfUrl": "https://s3.amazonaws.com/..."
}Revise Quote (New Version)
POST /api/quotes/:id/revise
Permission: quotes:create | Plan: PRO (requires quote_versioning feature)
Creates a new version of the quote. Snapshots the current state into QuoteVersion, then replaces items and totals with the new data. Increments the version field.
Request Body: Same schema as Create Quote (createQuoteSchema).
{
"clientId": "clxyz...",
"items": [...],
"notes": "Versión actualizada con descuento",
"shippingCost": 0
}Response 200 OK: Returns the updated quote with new items and client.
Errors:
| Status | Error | When |
|---|---|---|
| 403 | "Función no disponible en el plan actual" | Plan doesn't support versioning |
| 404 | "Cotización no encontrada" | Quote not found in tenant |
Side Effects:
- Creates a
QuoteVersionsnapshot of the previous state - Fires
quote.revisedwebhook event - Logs audit entry with action
quote.revised
Get Version History
GET /api/quotes/:id/versions
Permission: quotes:read
Retrieve all previous versions of a quote, ordered by version number descending.
Response 200 OK:
[
{
"id": "clxyz...",
"version": 1,
"snapshot": {
"clientId": "...",
"items": [...],
"notes": "...",
"subtotal": 15000,
"tax": 2400,
"total": 17400
},
"createdBy": "Carlos Méndez",
"createdAt": "2026-03-30T10:00:00.000Z"
}
]Get Signature Data
GET /api/quotes/:id/signature
Permission: quotes:read
Retrieve the digital signature data for a quote, if one exists.
Response 200 OK:
{
"signature": {
"type": "draw",
"data": "data:image/png;base64,...",
"signerName": "María López",
"signerEmail": "maria@distribuidora.mx",
"signedAt": "2026-03-31T14:30:00.000Z"
}
}If no signature exists, signature will be null.