Sign in →
Features2 min read

CPQ: Configure · Price · Quote

Sales-led B2B quoting workflow that turns a prospect conversation into a signed quote, an internal approval, a customer-accepted contract, and a provisioned subscription — all in one transaction.

Updated 2026-06-15Suggest edits
Docs Features CPQ

What is CPQ?#

CPQ stands for Configure · Price · Quote. It is the industry-standard sales motion for enterprise B2B deals where pricing is negotiated, contracts require legal review, and procurement teams demand a formal quote document before signing.

Without CPQ, sales-led deals collapse into ad-hoc spreadsheets, manually drafted PDFs, and out-of-band approval emails. The result: pricing leaks, missed approvals, and post-deal provisioning drift. CPQ replaces that chaos with one workflow: the sales rep configures the deal (which offering, which rate plan, which discount), the system prices it (mathematically, against locked rate plan versions), and a formal quote is generated for approval and signature.

BUILD THE DEAL
Configure
Sales rep selects an existing customer or enters a prospect (email + company), picks an offering, picks the rate plan (and version), then adds line items with optional discounts.
LOCK THE MATH
Price
Subtotal, discounts, and Total ACV (Annual Contract Value) computed in real time. Rate plan version pinned at quote create — pricing cannot drift during negotiation.
GENERATE THE DOCUMENT
Quote
Internal approval routed via configurable rules. On approval, a branded PDF is generated and a signed URL is emailed to the customer for click-through acceptance.
INFO
Aforo's CPQ targets enterprise SaaS, AI infrastructure, and API platform deals where negotiation is the norm. It is NOT a replacement for self-serve checkout — for that, use + Offerings + the storefront.

How Aforo Provides CPQ#

Aforo's CPQ ships as a first-class feature inside Pricing Studio. There is no separate CPQ product to license, no third-party integration to configure, no Salesforce CPQ data sync to maintain. CPQ lives alongside your rate plans, offerings, and subscriptions — because quotes reference all three and convert into the fourth atomically.

The architecture mirrors what Zuora, Chargebee, and Salesforce CPQ do for the largest SaaS companies — but co-located in your billing platform so there is no data-sync lag, no out-of-band reconciliation, and no separate audit trail. Approvals, acceptance, PDF generation, and conversion all share the same tenant context, the same access controls, and the same audit trail as the rest of Aforo.

CapabilityWithout CPQWith Aforo CPQ
Pricing accuracyReps copy/paste from outdated spreadsheetsRate plan version locked at quote create — math is canonical
Approval governanceSlack DMs and email chainsConfigurable rules engine (discount %, total ACV, discount amount)
Customer signaturePDF attached to email, signed manuallyClick-through acceptance with IP + UA + timestamp audit
ProvisioningManual handoff to ops team post-signatureAtomic Quote → Subscription conversion in one transaction
Audit trailScattered across email + spreadsheet + ticketingEvery quote action captured in one immutable audit trail

Quote Lifecycle (7-State Machine)#

Every quote moves through a deterministic state machine. Transitions are enforced server-side — invalid transitions are rejected immediately, and repeating a transition you've already made (for example, submitting the same draft for approval twice) is a safe no-op.

quote-state-machine.txt
        ┌─────────┐  submitForApproval  ┌──────────┐  approve   ┌──────────┐
        │  DRAFT  │─────────────────────▶│ IN_REVIEW│───────────▶│ APPROVED │
        │         │                      │          │            │          │
        └─────┬───┘                      └─────┬────┘            └─────┬────┘
              │                                │ reject                │ send
              │ expire                         │                       │
              │                                ▼                       ▼
              │                          ┌──────────┐           ┌──────────┐
              ├─────────────────────────▶│ REJECTED │           │   SENT   │
              │                          │ (terminal)│           │          │
              ▼                          └──────────┘           └─────┬────┘
        ┌──────────┐                                                  │ accept (customer)
        │ EXPIRED  │                                                  │
        │ (terminal)│                                                 ▼
        └──────────┘                                            ┌──────────┐  convertToSubscription
                                                                │ ACCEPTED │──────────────┐
                                                                │          │              ▼
                                                                └──────────┘       ┌──────────────┐
                                                                                   │  CONVERTED   │
                                                                                   │  (terminal)  │
                                                                                   └──────────────┘

State Definitions#

StateMeaningWho Can Transition Out
DRAFTSales rep is still editing. Customer cannot see this. Pricing math runs but quote_number is not yet visible externally.Sales rep (submit) or scheduler (expire)
IN_REVIEWSubmitted to internal approval queue. Approval rules engine routed the quote to a specific approver tier.Approver (approve / reject)
APPROVEDInternal sign-off complete. Customer-facing PDF can now be generated and the signed URL minted.Sales rep (send)
SENTSigned-URL acceptance link emailed to the customer. Quote is visible to customer at /quote/:token (public route).Customer (accept / reject) or scheduler (expire)
ACCEPTEDCustomer clicked Accept after agreeing to terms. IP + UA + timestamp captured. Quote is legally binding under click-through doctrine.Conversion job (convertToSubscription)
CONVERTEDAtomically created: Customer (if prospect) + Subscription + initial API key (if Standard/Agentic API). Terminal state.
REJECTEDEither internal approver rejected during IN_REVIEW, or customer rejected after SENT. Terminal state.
EXPIREDHourly scheduler flipped DRAFT/SENT to EXPIRED past valid_until. Terminal state.

Quote Builder Wizard (5 Steps)#

Sales reps build quotes through a 5-step wizard at /cpq/new. The wizard preserves field state on Back, validates each step before allowing Next, and shows a sticky ACV sidebar on the right that recomputes in real time as line items are added.

1Step 1 — Customer: Pick an existing customer from the dropdown (customer_id is set, prospect fields cleared) OR enter prospect details for a net-new lead (prospect_email + prospect_name + prospect_company; customer_id is null until conversion creates one).
2Step 2 — Configure: Pick the offering, then the rate plan, then the rate plan version. The rate plan version is what gets pinned to the quote — once chosen, the math is locked even if the operator later modifies the rate plan.
3Step 3 — Price: Add line items (quantity, unit price, discount). The wizard auto-computes subtotal, total discount, and Total ACV. Each line item supports per-line discount in addition to the quote-level discount.
4Step 4 — Terms: Set payment terms (NET_15 / NET_30 / NET_45 / NET_60 / NET_90 / DUE_ON_RECEIPT), billing frequency (MONTHLY / QUARTERLY / SEMI_ANNUALLY / ANNUALLY), term length in months, valid_until (default 30 days), and currency (defaults to USD).
5Step 5 — Review: Final read-only preview. Sales rep can Save as Draft (stays editable) or Submit for Approval (transitions DRAFT → IN_REVIEW, triggers approval rules engine).
PRO TIP
The sticky ACV sidebar on the right of the wizard recalculates instantly on every line item change. This is what reps show on screen-shares during live deal negotiations — no spreadsheet required.

Quote Fields Reference#

Each quote carries the following fields.

Identity & Tenant Fields#

FieldTypeDescription
quote_idUUID PKPrimary key. Generated server-side.
quote_numberVARCHAR, unique per tenantHuman-readable sequential number (e.g., Q-2026-00042). Visible to customer on PDF.
tenant_idVARCHAR(64) NOT NULLMulti-tenant scope. Every quote belongs to exactly one Aforo tenant.
billing_entity_idUUID NOT NULLWhich billing entity (legal entity) issues this quote. Drives invoice branding, tax jurisdiction, and bank details after conversion.
statusENUM (7 values)DRAFT / IN_REVIEW / APPROVED / SENT / ACCEPTED / CONVERTED / REJECTED / EXPIRED.

Customer Fields (Dual-Mode)#

A quote is built for either an existing customer or a prospect — never both. When a prospect quote is accepted and converted, Aforo automatically creates a customer record for them — idempotent, so a retried conversion never produces a duplicate.

FieldTypeWhen Set
customer_idVARCHAR(36), nullableNOT NULL when quoting an existing customer. NULL until conversion when prospect quote is accepted.
prospect_emailVARCHAR(255), nullableRequired when customer_id is null. Used as the destination for the signed-URL acceptance email.
prospect_nameVARCHAR(255), nullableBuyer’s full name. Required when customer_id is null.
prospect_companyVARCHAR(255), nullableCompany name. Required when customer_id is null. Becomes the customer’s display_name on conversion.

Offering & Rate Plan Fields#

FieldTypeDescription
offering_idVARCHAR(36) NOT NULLWhich offering this quote sells. Must be PUBLISHED and visible to the tenant.
rate_plan_idVARCHAR(36) NOT NULLWhich rate plan within the offering. Drives pricing model + tiers.
rate_plan_version_idVARCHAR(36) NOT NULLPinned version. The whole point of CPQ — the operator can edit the rate plan after quote creation and the quote’s math doesn’t change.

Pricing Fields (NUMERIC 22,8 precision)#

FieldTypeDescription
currencyCHAR(3) NOT NULLISO 4217 (USD, EUR, GBP, etc.). Defaults to USD.
subtotalNUMERIC(22,8) NOT NULLSum of (quantity × unit_price) across all line items, before discounts.
discountNUMERIC(22,8) DEFAULT 0Total discount applied across the quote (line-level + quote-level summed).
total_acvNUMERIC(22,8) NOT NULLAnnual Contract Value. Used as the canonical metric for approval routing and dashboard aggregation.

Terms Fields#

FieldTypeValues
payment_termsENUMNET_15, NET_30 (default), NET_45, NET_60, NET_90, DUE_ON_RECEIPT
billing_frequencyENUMMONTHLY, QUARTERLY, SEMI_ANNUALLY, ANNUALLY (default)
term_length_monthsINT NOT NULLLength of the contract in months. Defaults to 12.
valid_untilTIMESTAMPTZ NOT NULLQuote expiration. Hourly scheduler flips DRAFT/SENT to EXPIRED past this date.

Acceptance & Audit Fields#

FieldTypeDescription
acceptance_tokenVARCHAR(64)32-byte cryptographic random in hex (256 bits entropy). Acts as the bearer for the public /quote/:token route.
acceptance_token_expires_atTIMESTAMPTZToken-specific expiry. Defaults to valid_until but can be shorter for time-pressure deals.
accepted_atTIMESTAMPTZ, nullableServer timestamp when customer clicked Accept.
accepted_ipINET, nullableCustomer IP captured at accept time. Audit trail for click-through doctrine.
accepted_user_agentTEXT, nullableCustomer browser/device fingerprint. Audit trail.
signature_envelope_idVARCHAR(128), nullablev2 DocuSign integration. Null in v1.

Conversion Output Fields#

FieldTypeDescription
converted_subscription_idVARCHAR(36), nullableSet automatically when the quote converts from ACCEPTED to CONVERTED.
converted_customer_idVARCHAR(36), nullablePopulated atomically on conversion if quote was prospect-only (customer was created from prospect during conversion).

Audit Timestamps#

Every quote carries created_at, updated_at, submitted_at, approved_at, sent_at, rejected_at, expired_at, and converted_at — all TIMESTAMPTZ, populated by the state machine on transition.

Line Item Fields#

Each quote has one or more line items. Deleting a DRAFT quote removes its line items automatically.

FieldTypeDescription
line_item_idUUID PKPrimary key. Generated server-side.
quote_idUUID, FKParent quote. CASCADE delete.
line_item_typeENUMSUBSCRIPTION (base recurring fee), USAGE (metered overage), DISCOUNT (negative amount), ONE_TIME (setup fee, etc.)
descriptionTEXTHuman-readable line description. Renders verbatim on the PDF.
quantityNUMERIC(22,8) NOT NULLQuantity for this line. For SUBSCRIPTION, usually 1. For USAGE, the committed quantity (e.g., 1M API calls/month).
unit_priceNUMERIC(22,8) NOT NULLPrice per unit in quote currency.
amountNUMERIC(22,8) NOT NULLquantity × unit_price — discount. Computed server-side, never trusted from client.
discount_percentageNUMERIC(5,2), nullablePer-line discount %, 0–100. Used by approval rules (DISCOUNT_PCT threshold).
discount_amountNUMERIC(22,8), nullablePer-line flat discount. Used by approval rules (DISCOUNT_AMOUNT threshold).
metadataJSONBFree-form key/value metadata (e.g., {"sku": "ENT-AGI-001"}).
sort_orderINTDisplay order on the PDF and in the line items tab.
WARNING
The amount field is always recomputed server-side from quantity, unit_price, discount_percentage, and discount_amount. Client-supplied amounts are ignored. This is the load-bearing pricing invariant.

Approval Rules Engine#

When a quote is submitted for approval, Aforo evaluates all configured rules for your tenant and selects the first matching rule, ordered by threshold (lowest threshold wins). The matched rule's required approver role becomes the tier the quote routes to.

Rule Types (v1)#

Rule TypeThreshold FieldExample
DISCOUNT_PCTdiscount_threshold (NUMERIC 5,2)"If quote discount > 15%, require APPROVER role" — Sales reps can give up to 15% without approval; above that triggers manager sign-off.
TOTAL_ACVacv_threshold (NUMERIC 22,8)"If Total ACV > $250,000, require APPROVER role" — Big-dollar deals always require approval regardless of discount.
DISCOUNT_AMOUNTdiscount_amount_threshold (NUMERIC 22,8)"If total discount > $50,000, require APPROVER role" — Absolute-dollar guard regardless of deal size.
CUSTOM(v2 placeholder)Deferred to v2 — a future tenant might want "If customer’s industry = HEALTHCARE, require legal review" or other graph-walk rules.

Rule Fields#

FieldTypeDescription
rule_idUUID PKPrimary key.
tenant_idVARCHAR(64) NOT NULLPer-tenant rule scope. Tenant A’s rules never apply to Tenant B.
nameVARCHAR(255) NOT NULLHuman-readable rule name (e.g., "Standard discount guard").
rule_typeENUMDISCOUNT_PCT, TOTAL_ACV, DISCOUNT_AMOUNT, CUSTOM.
discount_thresholdNUMERIC(5,2), nullableUsed when rule_type = DISCOUNT_PCT.
acv_thresholdNUMERIC(22,8), nullableUsed when rule_type = TOTAL_ACV.
discount_amount_thresholdNUMERIC(22,8), nullableUsed when rule_type = DISCOUNT_AMOUNT.
required_approver_roleVARCHAR(64) NOT NULLKeycloak role that the approving user must hold. Typically "APPROVER" or "OWNER".
statusENUMACTIVE (rule is evaluated) or DISABLED (rule is skipped).

Approval Records#

When the engine routes a quote to an approver tier, a quote_approvals row is created with decided_at = NULL. The pending-queue UI scans the partial index idx_quote_approvals_pending WHERE decided_at IS NULL for the hot path.

FieldTypeDescription
approval_idUUID PKPrimary key.
quote_idUUID, FKParent quote.
required_roleVARCHAR(64) NOT NULLSnapshot of the rule’s required_approver_role at submit time. Pinned so role taxonomy changes don’t invalidate in-flight quotes.
rule_idUUID, nullableWhich rule routed this quote here. Null if manually routed.
requested_byVARCHAR(255)User ID of the sales rep who submitted.
requested_atTIMESTAMPTZ NOT NULLWhen the approval was requested.
decided_byVARCHAR(255), nullableUser ID of the approver. Null while pending.
decided_atTIMESTAMPTZ, nullableWhen the approver clicked Approve or Reject.
decisionENUM, nullableAPPROVED or REJECTED. Null while pending.
rejection_reasonTEXT, nullableRequired if decision = REJECTED. Surfaced back to the sales rep.

Customer Acceptance Flow#

After internal approval, the sales rep clicks Send to Customer. This (a) mints a 32-byte cryptographic acceptance token, (b) constructs a customer-facing URL https://<tenant-storefront>/quote/<token>, (c) emails it to the customer (or copies it to clipboard for manual delivery), and (d) transitions the quote to SENT.

Public Route (No Auth Required)#

The token IS the bearer. There is no JWT, no login, no SSO. The customer clicks the link, lands on QuoteAcceptPage in the storefront-ui, and sees the quote with the tenant's branding (logo, legal name, address from billing_entity).

Page StateWhen ShownCTA
LoadingInitial token fetch in flightSkeleton with role=status aria-busy + shimmer
Token-not-foundToken mistyped, missing, or never existed"Quote not found" page; no CTA
ExpiredToken past valid_until OR acceptance_token_expires_at"This quote has expired" + sales-rep contact email
ClosedQuote status ≠ SENT (already accepted, rejected, etc.)"This quote is no longer accepting responses"
Has-dataStatus = SENT, token validQuote summary + line items + Accept (with terms checkbox) / Decline / Request changes
AcceptedCustomer clicked Accept, conversion succeeded"Welcome! Your subscription is now active. Check your email for next steps."
RejectedCustomer clicked Decline"Thank you for your response. Your sales rep has been notified."

Click-Through Acceptance Doctrine#

v1 ships with click-through acceptance — legally binding under U.S. case law (Specht v. Netscape, ProCD v. Zeidenberg) and equivalent EU + APAC jurisdictions. On Accept, the server captures:

  • Timestamp — accepted_at (server clock, TIMESTAMPTZ)
  • IP address — accepted_ip (INET; X-Forwarded-For respected behind ALB)
  • User agent — accepted_user_agent (TEXT; full UA string for forensics)
  • Token — the cryptographic token itself (proof the customer received the email)
INFO
v2 wires DocuSign for high-touch enterprise deals where signature ceremony is required. The schema already carries signature_envelope_id VARCHAR(128) for that — null in v1.

Brute-Force Defense#

The public route is protected by Kong route route-pricing-public-quotes with per-IP rate limit 10 requests/min. All error paths (token expired, status closed, cross-tenant probe) return HTTP 404 — same shape as not-found — so attackers cannot enumerate token validity by comparing differential error responses.

Quote → Subscription Conversion#

When the customer accepts, Aforo converts the quote into a live subscription as one atomic operation. If any step fails, the whole conversion rolls back and the quote stays ACCEPTED so it can be safely retried. Converting an already-converted quote is a no-op that returns the existing subscription.

Conversion Steps (in order)#

1Validate that the quote is ACCEPTED. An already-converted quote returns its existing subscription (idempotent); a quote in any other state is rejected.
2Resolve the customer — use the existing customer on the quote, or create one automatically for a prospect quote.
3Create the subscription, inheriting the offering, rate plan version, and billing entity from the quote.
4For Standard API and Agentic API products, automatically issue an initial API key. AI Agent and MCP Server products are registered by the operator first.
5Mark the quote CONVERTED and link it to the new subscription and customer.
6Notify downstream systems — welcome email to the customer, ops handoff, and analytics — only after the conversion has fully committed.
WARNING
If subscription creation throws (e.g., billing entity misconfiguration), the whole transaction rolls back. The quote stays ACCEPTED. The customer's Accept click is preserved. The sales rep can retry conversion from the drawer.

CPQ Inbox + Browse Page#

The merged CPQ page at /cpq replaces the prior split between Quotes and Approval Queue. It combines an inbox-style attention strip for approvers with a full browse-and-filter quote table for everyone.

Page Anatomy#

SectionAudienceContent
"Pending your approval" attention cardUsers with APPROVER, OWNER, or ADMIN roleLive count of quotes routed to this user’s approver tier. Click to filter the table to those quotes.
KPI stripEveryone6 cells: Total Quotes / Draft / In Review / Approved / Sent / Total ACV (lifetime). Updates live as quotes transition.
Status filter chipsEveryoneAll / Draft / In Review / Approved / Sent / Accepted / Converted / Rejected / Expired. Multi-select; aria-pressed for active.
Search inputEveryoneFilters by quote_number, customer name, prospect email, prospect company.
Quote tableEveryoneColumns: Quote # / Customer / Offering / Status / Total ACV / Valid Until / Updated. Click row to open drawer.
"+ New Quote" buttonUsers with SALES_REP, OWNER, or ADMIN roleRoutes to /cpq/new (5-step wizard).
PRO TIP
The attention card is gated by role at three layers: sidebar visibility, ROUTE_ROLES route guard, AND in-page useCurrentUser check. Non-approvers see the table and can submit drafts, but the approval bucket is invisible to them.

Quote Detail Drawer (5 Tabs)#

Clicking any quote row opens a slide-in drawer with 5 tabs. Action buttons in the drawer header are state-aware — they appear only when the state machine allows that transition.

TabContent
OverviewQuote metadata: number, customer, offering, rate plan, term length, billing frequency, payment terms, valid_until, Total ACV. Status badge top-right.
Line ItemsSortable table of all line items with description, qty, unit_price, discount %, amount, type. Total row at the bottom mirroring quote subtotal/discount/ACV.
ApprovalsChronological list of every quote_approvals row. Approver name, required role, decision (APPROVED/REJECTED), decided_at, rejection_reason if applicable. For pending approvals, APPROVER-role users see inline Approve + Reject buttons.
PDF PreviewEmbedded iframe rendering /quotes/{id}/preview (Thymeleaf HTML). For PDF download, button hits /quotes/{id}/pdf which returns application/pdf bytes for browser native download.
ActivityTimeline of every state transition: submitted, approved, sent, accepted, converted. Each entry shows actor + timestamp + delta. Useful for forensic audit of long-running deals.

State-Aware Drawer Actions#

When Status =Buttons Visible (role-gated)
DRAFTEdit (returns to wizard), Submit for Approval, Delete (CASCADE removes line items + approvals)
IN_REVIEWApprove + Reject (APPROVER only), Recall (sales rep can pull back to DRAFT)
APPROVEDSend to Customer (mints token + email), Edit (back to DRAFT, voids prior approval), Mark as Expired
SENTResend Email, Copy Link, Expire Manually, Edit (back to DRAFT — voids token)
ACCEPTEDConvert to Subscription (retry if initial conversion failed), View Customer
CONVERTEDView Subscription, Download PDF, No edits allowed (terminal state)
REJECTED / EXPIREDClone to New Quote (creates a fresh DRAFT pre-populated with the same line items)

Roles & RBAC#

Two CPQ-specific composite Keycloak roles ship in realm-aforo-prod.json. Both compose MEMBER for baseline platform access.

RoleComposesCapabilities
SALES_REPMEMBERCreate + edit DRAFT quotes, submit for approval, send approved quotes, view all quotes for their tenant.
APPROVERMEMBERView pending approval queue, Approve/Reject IN_REVIEW quotes, view "Pending your approval" attention card.
OWNER / ADMIN(broader platform roles)All CPQ operations + approval rule CRUD + override approver decisions.

v1 Scope & v2 Roadmap#

v1 ships the core flow: configure → price → quote → internal approval → customer acceptance → auto-conversion. Each deferred item below becomes its own follow-up prompt when an enterprise customer asks.

Deferred Capabilityv2 Plan
DocuSign / e-signatureThe schema already carries signature_envelope_id. v1 uses click-acceptance with timestamp + IP + UA audit — legally binding under click-through doctrine. v2 wires DocuSign for high-touch enterprise deals where signature ceremony is required.
Multi-step approval routingSales → Legal → Finance → CFO chains. v1 ships single-level approval. v2 introduces approval workflows with sequential or parallel approver tiers.
Contract redlines / negotiation trackingv1 forces the sales rep to regenerate the quote if terms change. v2 introduces a redline diff view between quote revisions.
Renewal automationv1 renewals use standard rate plan auto-renew. v2 preserves negotiated terms across renewal cycles — e.g., a 20% discount granted in year 1 carries forward.
Quote templates / pre-built bundlesv1 builds every quote from scratch. v2 adds a template library so reps can clone a "Standard Enterprise" template and just edit the line quantities.
Volume commitments / minimum spend with true-upv1 supports discounts + custom rates. v2 adds annual minimum spend commitments with quarterly true-up reconciliation.
Multi-product bundles in a single quotev1 limits a quote to one rate plan. v2 supports multiple rate plans per quote (e.g., "Enterprise API + Premium Support + Custom AI Model" in one signed contract).

CPQ depends on the broader Aforo monetization fabric. Quotes reference rate plan versions, offerings, and billing entities; conversions produce subscriptions that flow into the billing engine.

Plan Studio
Rate plans + offerings that quotes reference. Pin rate plan versions to lock pricing math.
Billing & Invoicing
After conversion, the subscription drives invoicing through the 10-stage billing pipeline.
Margin Guards
Quote ACV is one input to margin protection — deep discounts can trigger margin alerts pre-approval.
Audit & Compliance
Every quote state transition is captured in immutable audit logs. SOC2-ready out of the box.