Read analytics and search across documents
This guide covers two read-only areas. Analytics returns aggregates over sales and purchase activity. Global search finds individual records by text. Both need a staff login, and neither changes data.
1. What analytics is and is not
Analytics has no mutations. Every analytics operation reads the sales and purchase ledger and returns aggregates: funnels, totals, trends and worklists.
There is no report builder and no /reports module. In this guide, "reports" means the queries below.
Use a staff user JWT (see Authentication). Customer portal and buyer tokens are refused. The organization comes from the credential. Never pass an organization id as an argument.
Analytics is a plan feature. Every current plan includes it.
For how permissions decide what you can see, see tenant permissions. An empty result can be the correct answer for your scope, not a failure.
2. The three rules every analytics query follows
Rule one: a mandatory window of at most 400 days
Every v2 analytics query takes range: AnalyticsRangeInput!. It has three fields: from: DateTime!, to: DateTime!, and an optional scope. The one exception is inventoryAnalyticsV2, which takes InventoryRangeInput! (section 3). The same window rules apply to it.
A request is refused before any query runs in these cases:
fromortois not a valid date. The message isInvalid analytics range: from/to must be valid dates.fromis afterto. The message isInvalid analytics range: "from" must be before "to".- The window is longer than 400 days. The message is
Analytics range too large: maximum 400 days (requested ~N).
For longer history, send several windows of at most 400 days and combine them in your client. A 12-month or financial-year window fits in one request.
The window also sets the bucket size in trend data:
- Up to 31 days: daily buckets.
- Up to 120 days: weekly buckets.
- Longer windows: monthly buckets.
Buckets are cut in the tenant's business time zone. Bucket strings look like this:
- Day:
YYYY-MM-DD - ISO week:
IYYY-IW - Month:
YYYY-MM
Rule two: results are clamped to what the caller may see
Two users can get different numbers from the same query. The server decides the scope tier from the credential, not from anything you send:
- Holding
analytics:organization:readgives org-wide results. This tier isALL. - Without that permission, you see only your own reporting subtree. The tier is
TEAMif you have people reporting to you, andMINEif you do not. - The tenant
ADMINrole alone does not grant org-wide analytics.
range.scope (MINE, TEAM or ALL) can only narrow the result. Requesting ALL without the permission is silently clamped, not refused. If you omit range.scope, you get the widest scope you are allowed.
Attributable queries echo the tier they resolved: DashboardHome.scope and SalesSignals.scope. Read that field and use it to label the number you show.
A caller's numbers include customers assigned to them, and customers they created that were never assigned. Reassigning a customer changes the numbers.
Scope changes take effect immediately, even though results are cached (rule three). Do not treat the scope you send as authorization.
Rule three: results are cached for about 300 seconds
Analytics is not real-time. Results are cached for about 300 seconds, so a just-created invoice can take about five minutes to appear. Poll no faster than that.
Do not use analytics to confirm a write. For read-after-write, use the document queries described in Quote to cash.
Shifting to, for example to a rolling "now", makes a different request. That request does not benefit from the cache.
A number from analytics can lag a write by about five minutes. If your integration needs to know whether a document was saved, re-read that document instead of waiting for a dashboard total to move.
3. Current analytics operations
The v2 operations below all take range: AnalyticsRangeInput! unless noted. Money is in the organization's base currency, as raw numbers. See money, pricing and tax.
For the request shape, see GraphQL API. For every type and field, see the API reference.
Scoped to the caller
These operations apply the scope rules from section 2. Any staff login can call them, except salesSignals, which also requires analytics:signals:read. procureToPayAnalytics also needs the purchase orders feature on your plan; without it the call is refused with a plan error.
| Operation | Returns | Notes |
|---|---|---|
quoteToCashAnalytics(range) | QuoteToCashAnalytics | Funnel, conversion and win rates, pipeline value, cycle times, trend |
procureToPayAnalytics(range) | ProcureToPayAnalytics | Funnel, lead time, on-time receipts, open PO value, trend. Needs the purchase orders feature |
salesPerformanceAnalytics(range) | SalesPerformanceAnalytics | Leaderboard, commissions for the caller, team size |
customerEngagementAnalytics(range) | CustomerEngagementAnalytics | Active and new customers, email open rate, login trend, most engaged and at-risk customers |
dashboardHome(range) | DashboardHome | Scope, KPIs, action queue, revenue trend |
customerInsights(partnerId: ID!, range) | CustomerInsights | A customer outside your book returns the same "not found" as a missing id |
salesSignals(range, filter: SalesSignalsFilterInput, page: SalesSignalsPageInput) | SalesSignals | Requires analytics:signals:read. See below. |
appActivityAnalytics, appActivityCustomer, appActivityGuests, appActivityDrilldown | Usage analytics | Buyer-app and customer-portal usage |
A dashboard request looks like this:
query Dashboard($range: AnalyticsRangeInput!) {
dashboardHome(range: $range) {
scope
kpis { revenue openOrderValue openOrders pipelineValue overdueReceivables commissionsPending }
actionQueue { draftQuotes ordersAwaitingFulfilment overdueInvoices pendingApprovals }
revenueTrend { bucket quotes orders invoiced collected }
}
}
Variables:
{ "range": { "from": "<ISO-8601 start>", "to": "<ISO-8601 end, at most 400 days after from>", "scope": "MINE" } }
A quote-to-cash request looks like this:
query QuoteToCash($range: AnalyticsRangeInput!) {
quoteToCashAnalytics(range: $range) {
funnel { stage count value }
conversionRatePct
winRatePct
pipelineValue
avgCycleDays { quoteToOrder orderToInvoice invoiceToPaid total }
trend { bucket quotes orders invoiced collected }
}
}
Sales signals
salesSignals returns a worklist of customer signals. It needs analytics:signals:read.
It uses offset pagination. page takes offset (default 0) and limit (default 25, between 1 and 100). The response is { rows, total, availableKeys, customersEvaluated, truncated, scope }. total counts rows after your filters are applied.
At most 300 customers are evaluated. If truncated is true, the worklist was capped, so do not treat it as complete.
The filter takes three fields:
signalKeys: [String!]takes a list of signal keys.minStake: Floatmust be zero or more. A value of0means "has money at stake".search: Stringmatches on customer name. It takes at most 200 characters.
query Signals($range: AnalyticsRangeInput!, $page: SalesSignalsPageInput) {
salesSignals(range: $range, page: $page) {
total
truncated
scope
rows { partnerId partnerName key severity headline stake }
}
}
Organization-wide only
These operations require analytics:organization:read. Without it, the request is refused with Org-wide analytics require the analytics:organization:read permission.
A scoped caller is refused. They do not get a narrowed result. These operations ignore range.scope.
| Operation | Notes |
|---|---|
inventoryAnalyticsV2(range: InventoryRangeInput!) | Takes InventoryRangeInput { from, to, warehouseId }, not AnalyticsRangeInput. warehouseId is optional. The V2 suffix is there because inventoryAnalytics is taken by a legacy query. |
receivablesAnalytics(range) | Aging, collection trend, dsoDays, avgDaysToCollect, payables (with dpoDays and avgDaysToPayVendors), top debtors |
returnsAnalytics(range) | Return rate, returns by reason and by product, return value, credit-note value |
catalogPerformanceAnalytics(range) | grossMargin.marginPct is computed over covered revenue only: revenue from lines whose variant has a cost. grossMargin.coveragePct is the share of revenue that is covered, and grossMargin.revenue is all revenue. Read coveragePct before you quote the margin. |
supplierScorecard(range) | One row per supplier. Also needs the purchase orders feature on your plan |
A receivables request looks like this:
query Receivables($range: AnalyticsRangeInput!) {
receivablesAnalytics(range: $range) {
dsoDays
avgDaysToCollect
}
}
Read the metrics correctly
Several metrics are easy to misread:
conversionRatePctis converted quotes divided by all quotes. It is not orders divided by quotes.winRatePctis won quotes divided by won plus lost quotes. Only decided quotes count.APPROVEDquotes count as open pipeline.- "Orders" means committed orders only:
CONFIRMED,PARTIALLY_INVOICED,INVOICED,PARTIALLY_SHIPPED,SHIPPEDandDELIVERED. Drafts and cancelled orders are excluded. collectedand paid counts include only money that actually moved. Credit-note and debit-note settlements are excluded.- Money rounds at the base currency's minor unit.
- Cycle-time legs are
nullwhen there are no linked pairs. dsoDaysis days sales outstanding: open invoices at the end of the range divided by invoices issued in the range, times the days in the range, to one decimal in base currency. It isnullwhen no invoice was issued in the range.dpoDaysis the same ratio for vendor bills.avgDaysToCollectandavgDaysToPayVendorsare different: the average days between an invoice or bill date and the payment allocated to it, counting only items that were paid. Credit notes are not netted, and the open balance is each invoice's current outstanding amount.- In
procureToPayAnalytics, the trend points reuse the same type with different meanings.quotesholds PO value andordersholds GRN value.
4. Legacy queries
The v1 queries below still exist, and the schema does not mark them as deprecated. They are legacy. None of them takes range: most take a period enum, b2bDashboard takes a DateRangeInput, and dashboardStats and inventoryAnalytics take no window at all. Do not rely on the window, scope and caching rules in section 2 for them. Do not build on them. They may be removed, and no date has been announced.
| Legacy query | Signature |
|---|---|
dashboardStats | dashboardStats |
salesAnalytics | salesAnalytics(period, currency) |
inventoryAnalytics | inventoryAnalytics(warehouseId) |
financialAnalytics | financialAnalytics(period, currency) |
purchaseAnalytics | purchaseAnalytics(period) |
customerAnalytics | customerAnalytics(period) |
performanceAnalytics | performanceAnalytics(period) |
trendsAnalytics | trendsAnalytics(period) |
b2bDashboard | b2bDashboard(dateRange) |
Use the v2 operations in section 3 instead. For stock figures, use inventoryAnalyticsV2.
5. Global search
globalSearch finds records by text across several entity types in one request. Its signature is globalSearch(query: String!, limit: Int! = 10, types: [GlobalSearchType!]): GlobalSearchResult!.
Some rules apply before any search runs:
- Calling
globalSearchrequiresproducts:product:read. Each result bucket also needs its own read permission, listed below. - Search is for staff only. A customer, portal or buyer token is refused with
Search is available to staff users only.
The response has one array per entity type. Each array is capped to limit on its own. limit is per bucket, defaults to 10, and is clamped to the range 1 to 100. The buckets are:
products, customers, suppliers, purchaseOrders, salesOrders, salesQuotes, invoices, grns, creditNotes, debitNotes, payments, inventory, brands, categories, warehouses, discounts, taxRates, taxGroups, stockMovements, stockAlerts, and stockAdjustments.
The stockAdjustments bucket is always empty. It is reserved.
Narrowing by type
types is optional. Omit it, or pass [], to search every type you may read. It can only narrow the search.
A type you lack permission for is silently dropped. Its bucket comes back empty, and no error is raised.
PRICING_TIER and STOCK_ADJUSTMENT are accepted in types but return nothing. There is no pricing-tier bucket, and stock adjustments are not searched.
The read permission for each type is:
| Type | Permission |
|---|---|
PRODUCT | products:product:read |
CUSTOMER | customers:customer:read |
SUPPLIER | suppliers:supplier:read |
PURCHASE_ORDER | purchase:purchase_order:read |
SALES_ORDER | sales:sales_order:read |
SALES_QUOTE | sales:quote:read |
INVOICE | payments:invoice:read. Covers sales and purchase invoices. Use the type field on the result to tell them apart. |
GRN | purchase:grn:read |
CREDIT_NOTE | sales:credit_note:read |
DEBIT_NOTE | purchase:debit_note:read |
PAYMENT | payments:payment:read |
INVENTORY | inventory:stock_level:read |
BRAND | products:brand:read |
CATEGORY | categories:category:read |
WAREHOUSE | inventory:warehouse:read |
DISCOUNT | products:discount:read |
TAX_RATE | products:tax:read |
TAX_GROUP | products:tax:read |
STOCK_MOVEMENT | inventory:stock_movement:read |
STOCK_ALERT | inventory:stock_level:read |
The scope rules from section 2 apply to customers, suppliers, sales orders, quotes, invoices, credit notes, debit notes and payments. The other types are org-wide for anyone who is allowed to read them.
Matching, ordering and results
- Matching is a case-insensitive substring match across number, name, reference and contact fields. There is no relevance ranking and no fuzzy matching.
- Products, customers and suppliers are ordered by name. Orders, quotes and invoices are ordered newest first. Inventory is ordered by quantity, highest first.
- Amounts are raw numbers. Invoices, sales orders and purchase orders include
currency. Quotes and payments do not. - Results carry ids, not URLs. Build your own deep links.
Search has no pagination
Global search does not paginate. There is no cursor, offset, total or hasNextPage. It fits none of the shapes described in errors, pagination and idempotency.
Treat it as a lookup and autocomplete API, not an enumerator. For complete lists, use the list queries in Read the catalog and resolve prices and Quote to cash, with their pagination.
A bucket whose sub-search fails comes back as an empty array, not as an error. An empty bucket can therefore mean any of three things: you lack permission, the record is outside your scope, or the sub-search failed. Do not read an empty bucket as "does not exist".
A search request looks like this:
query Search($q: String!, $limit: Int!, $types: [GlobalSearchType!]) {
globalSearch(query: $q, limit: $limit, types: $types) {
products { id name sku barcode brand { name } category { name } }
customers { id companyName firstName lastName email }
salesOrders { id orderNumber status totalAmount currency orderDate customer { id companyName } }
invoices { id invoiceNumber status totalAmount currency invoiceDate type }
}
}
Variables:
{ "q": "<search text>", "limit": 10, "types": ["PRODUCT", "CUSTOMER", "SALES_ORDER", "INVOICE"] }
customerCatalogSearch is a separate query. It returns only storefront-visible products, brands and categories, and it needs a customer token. This section covers staff-side search only. For storefront integrations, see the introduction.
6. Export analytics as CSV
A separate REST download exports quotes, sales orders, invoices, payments and sales signals for the same 400-day window and scope rules. It needs a per-domain export permission. The file is usually a password-protected spreadsheet, and a CSV only when the organization has turned off encrypted exports. See files and exports.
What not to do
- Don't poll analytics for real-time numbers. Poll no faster than about 300 seconds.
- Don't send a window longer than 400 days. Split it into windows of at most 400 days and combine them.
- Don't assume two users see the same numbers. To run an org-wide integration, use a user who holds
analytics:organization:read, and read the echoedscope. - Don't send
scope: ALLexpecting it to widen access. It cannot. - Don't treat a refusal on an org-wide operation as transient. The caller lacks the permission.
- Don't build on the v1 queries.
- Don't reconcile
collectedagainst all payment rows. Credit-note and debit-note settlements are excluded. - Don't read
conversionRatePctas orders divided by quotes. - Don't treat an empty search bucket as "does not exist".
- Don't use
globalSearchto enumerate records. - Don't hard-code currency symbols. Amounts are unformatted numbers in the organization's base currency, so format them on your side.