Product Insights
The product-insights endpoint analyses a single product's sales, purchases, and cost history and returns a prioritised list of business insights — margin compression, cost spikes, demand anomalies, supplier reliability problems, seasonality, currency effects, and more. Each insight comes with a severity, a plain-language explanation, the metrics behind it, and a concrete recommendation. Call it again later and it also tells you what has changed since the last time.
Base URL
https://forecastapi.com/v2
Authentication
Bearer YOUR_API_KEY
On this page
1. The Endpoint
A single synchronous POST. Send whichever of the three
datasets you have; the endpoint runs every analyser that its inputs support and returns all insights in one response.
You do not need to call /forecast or any other endpoint
first — the required statistical modelling happens internally.
Analyse Product
/product-insights
Example Request
{
"identifier": "SKU-12345",
"product_name": "Wireless Mouse",
"base_currency": "USD",
"sales": [
{"date": "2024-01-05", "quantity_sold": 12, "selling_price": 25.00},
{"date": "2024-02-05", "quantity_sold": 18, "selling_price": 25.00},
{"date": "2024-03-05", "quantity_sold": 9, "selling_price": 22.50}
],
"purchases": [
{"date": "2024-01-01", "quantity_purchased": 100, "cost_price": 10.00,
"supplier_id": "SUPP-001", "purchase_order_date": "2023-12-18",
"expected_delivery_date": "2023-12-30", "actual_delivery_date": "2024-01-01"}
],
"cost_history": [
{"date": "2024-01-01", "cost_price": 10.00},
{"date": "2024-03-01", "cost_price": 11.50, "change_reason": "supplier price increase"}
],
"insight_settings": {
"margin_threshold": 0.10,
"enable_seasonality_detection": true
}
}
At least one dataset is required. You may send
sales,
purchases,
cost_history, or any combination. More datasets
unlock more insight types (see the catalog).
2. Request Parameters
Top-level fields identify the product and configure the analysis. Unlike the forecasting endpoints, product-insights
takes no model,
periods, or
frequency — it works directly off the data you provide.
| Parameter | Type | Required | Description |
|---|---|---|---|
| identifier | string | Yes | Unique product identifier (SKU or product ID), max 255 chars. Echoed back, and used to match this product against its previous analysis for change tracking. |
| sales | array | Conditional | Historical sales transactions. See Datasets. |
| purchases | array | Conditional | Historical purchase orders / receipts. See Datasets. |
| cost_history | array | Conditional | A time series of unit cost changes. See Datasets. |
| product_name | string | No | Human-readable name, max 500 chars. Echoed back in product_info.name. |
| product_group_id | string | No | Group / category identifier, max 255 chars. Echoed back in product_info.group_id. |
| base_currency | string | No | ISO 4217 currency code (exactly 3 chars, e.g. USD). Used when phrasing currency and money-denominated insights. |
| baseline_period_days | string | number | No | How far back the "baseline" window reaches when comparing recent behaviour to normal. Either the string "auto" (default) or a number of days between 7 and 365. "auto" derives a sensible window from the span of your data. |
| insight_settings | object | No | Sensitivity thresholds and toggles. See Tuning & Thresholds. |
| locale | string | No | Language for insight title / description text. Defaults to "en". |
| tenant_context | string | No | Multi-tenant scoping tag, max 255 chars. When set, the previous-analysis lookup for change tracking is scoped to this tenant, so the same identifier can be reused across tenants without collisions. |
"Conditional" means at least one of sales,
purchases, or cost_history
must be present and non-empty. Sending none returns a 422.
3. Datasets
Every row in every dataset needs a date. Dates must be
YYYY-MM-DD or
YYYY-MM-DD HH:MM:SS, and the format must be
consistent within a dataset (you cannot mix date-only and date-time rows in the same array). Rows are
sorted chronologically for you, so you can send them in any order.
sales[]
| Field | Type | Required | Description |
|---|---|---|---|
| date | string | Yes | Transaction date. |
| quantity_sold | number ≥ 0 | Yes | Units sold in this transaction. |
| selling_price | number ≥ 0 | Yes | Unit selling price, in the row's currency. |
| selling_price_converted | number ≥ 0 | No | Unit selling price pre-converted to your base_currency. When present it is preferred for cross-currency comparisons, so multi-currency insights reflect real economics rather than raw FX. |
| currency | string (3) | No | ISO currency code for this row. Two or more distinct currencies across your sales unlock the currency insights. |
| customer_id | string | No | Customer identifier, max 255 chars. |
| customer_segment | string | No | Segment label, max 255 chars. |
purchases[]
| Field | Type | Required | Description |
|---|---|---|---|
| date | string | Yes | Purchase / receipt date. |
| quantity_purchased | number ≥ 0 | Yes | Units purchased. |
| cost_price | number ≥ 0 | Yes | Unit cost, in the row's currency. |
| cost_price_converted | number ≥ 0 | No | Unit cost pre-converted to base_currency; preferred for cross-currency comparisons when present. |
| currency | string (3) | No | ISO currency code for this purchase. |
| supplier_id | string | No | Supplier identifier, max 255 chars. Enables new-supplier detection and supplier cost-variance insights. |
| purchase_order_date | date | No | When the PO was raised. Together with the two delivery dates below, drives the lead-time reliability insight. |
| expected_delivery_date | date | No | Promised delivery date. |
| actual_delivery_date | date | No | When goods actually arrived. The gap versus expected_delivery_date is the delivery delay. |
cost_history[]
| Field | Type | Required | Description |
|---|---|---|---|
| date | string | Yes | Date this cost took effect. |
| cost_price | number ≥ 0 | Yes | Unit cost as of this date. |
| currency | string (3) | No | ISO currency code. |
| change_reason | string | No | Free-text note, max 500 chars. Surfaced back on recent_cost_change insights. |
4. Tuning & Thresholds
The optional insight_settings object controls how sensitive
the analysers are. Thresholds are expressed as fractions — 0.30
means "flag a 30% change." Anything you omit falls back to the defaults below.
| Field | Type | Range | Default | Controls |
|---|---|---|---|---|
| sales_threshold | number | 0.05 – 5.0 | 0.30 | Minimum change in sales volume/revenue that triggers a sales insight. |
| margin_threshold | number | 0.01 – 1.0 | 0.10 | Minimum margin movement that triggers a margin / profitability insight. |
| cost_threshold | number | 0.01 – 1.0 | 0.15 | Minimum cost-price change that triggers a cost insight. |
| purchase_threshold | number | 0.05 – 5.0 | 0.20 | Minimum change in purchase volume/pattern that triggers a purchase insight. |
| enable_seasonality_detection | boolean | true / false | true | Whether the seasonality analyser runs. Needs enough sales history to detect a cycle. |
| severity_levels | array<string> | critical, warning, info | all three | Reserved for filtering returned insights by severity. Accepted and validated today, but not yet applied — expect all severities back regardless. |
Lower the threshold to catch smaller movements (more, noisier insights); raise it to surface only large, decision-grade changes.
5. Response Structure
On success (HTTP 200) the body always has the same top-level
shape: a result object holding the analysis, and a
meta object holding timing and data-quality info.
{
"result": {
"tenant_context": null,
"identifier": "SKU-12345",
"product_info": { "name": "Wireless Mouse", "group_id": null },
"base_currency": "USD",
"insights": [
{
"class": "margin",
"type": "margin_compression",
"severity": "warning",
"title": "Margin dropped 8.2% versus baseline",
"description": "Average gross margin fell from 58.0% to 49.8% over the recent period.",
"metrics": {
"current_margin_percent": 49.8,
"baseline_margin_percent": 58.0,
"margin_change_percentage": -8.2,
"transactions_analyzed": 3
},
"recommendation": {
"action": "review_pricing",
"description": "Review selling price or negotiate cost to protect margin.",
"priority": "high"
},
"confidence": "medium",
"date": "2024-03-05",
"change_status": "new"
}
],
"summary": {
"total_insights": 1,
"severity_distribution": { "critical": 0, "warning": 1, "info": 0 },
"categories": ["profitability"],
"analysis_period": { "start_date": "2024-01-01", "end_date": "2024-03-05", "total_days": 65 },
"data_sources_analyzed": ["sales", "purchases", "cost_history"]
},
"comparison": null
},
"meta": {
"timing": { "validation": 2.1, "analysis": 148.7, "total": 151.9 },
"data_quality": {
"total_data_points": 6,
"datasets_provided": ["sales", "purchases", "cost_history"]
}
}
}
result fields
| Field | Type | Description |
|---|---|---|
| identifier | string | The product identifier, echoed from the request. |
| tenant_context | string | null | The tenant tag, echoed from the request (null if not sent). |
| product_info | object | { name, group_id } — echoed from the request; either may be null. |
| base_currency | string | null | Echoed from the request. |
| insights | array | The insights, sorted most-urgent first (by severity, then confidence). May be empty if nothing crossed a threshold. See Anatomy of an Insight. |
| summary | object | Roll-up counts and metadata about this analysis (see below). |
| comparison | object | null | null on the first-ever analysis of this product; otherwise a diff against the previous run. See Change Tracking. |
result.summary fields
| Field | Type | Description |
|---|---|---|
| total_insights | integer | Number of entries in insights. |
| severity_distribution | object | Counts per severity: { critical, warning, info }. Always present, zeros included. |
| categories | array<string> | Distinct business categories represented in the insights (see the value list below). |
| analysis_period | object | { start_date, end_date, total_days } spanning all datasets. Dates are null and total_days is 0 if no dates were parseable. |
| data_sources_analyzed | array<string> | Which datasets were actually present and analysed — subset of ["sales","purchases","cost_history"]. |
summary.categories — possible values
Each insight type maps to one business category. On this single-product endpoint you will see these values:
Portfolio-level categories (pricing,
customer_risk,
portfolio_risk,
categories,
inventory_management) only appear via
batch analysis, which compares products against each other.
Anatomy of an Insight
Every entry in insights has the same envelope. The
metrics object is the only part whose keys vary — they depend
on the insight type (documented in the
catalog).
| Field | Type | Description |
|---|---|---|
| class | string | The analyser family that produced it — e.g. sales, margin, cost, purchase, currency. Multiple types can share a class. |
| type | string | The specific insight, e.g. margin_compression. The full set is in the catalog. |
| severity | string | How urgent: critical warning info. Insights are sorted by this, descending. |
| title | string | Short headline, localised to locale. |
| description | string | One or two sentences explaining what was detected. |
| metrics | object | The numbers behind the insight. Keys depend on type; numeric values are rounded to 6 decimals. |
| recommendation | object | { action, description, priority } — a suggested next step. priority is one of critical, high, medium, low. |
| confidence | string | How much data backs the insight: high, medium, or low. Sparse history yields lower confidence. Secondary sort key after severity. |
| date | string | The date the insight is anchored to (YYYY-MM-DD), typically the most recent relevant data point. |
| change_status | string | How this insight compares to the previous analysis: new changed unchanged. On a first-ever analysis every insight is new. |
| translation_key | string | Optional. Present on a few types (e.g. sales anomalies) to support client-side re-translation. Accompanied by translated_text. |
Consuming insights generically: switch on type for
display logic, read severity for prioritisation, and treat
metrics as a bag of numbers you render on demand — new metric keys can be
added within a type without being a breaking change.
6. Insight Catalog
Which analysers run depends on which datasets you send. The tables below group every insight
type by the dataset that unlocks it, with the severity levels it
can emit and its most important metrics. A type only appears in the response when its trigger condition (usually a change
beyond the relevant threshold) is met.
Requires sales
| type | Severity | What it flags | Key metrics |
|---|---|---|---|
| sales_volume_anomaly | info → critical | An unusual spike or drop in units sold versus the expected/baseline level, or a shift in demand pattern. | change_percentage, anomaly_type (spike/drop), current_quantity, expected_quantity |
| revenue_change | info → warning | A material change in total revenue between the baseline and recent windows. | baseline_revenue, recent_revenue, revenue_change_percentage |
| margin_compression | warning → critical | Gross margin shrinking versus baseline (needs sales plus a cost signal). | current_margin_percent, baseline_margin_percent, margin_change_percentage |
| low_margin_alert | warning → critical | Margin sitting below a healthy absolute floor, regardless of trend. | current_margin_percent, minimum_threshold, margin_deficit |
| profitability_trend | info → warning | Margin steadily improving or declining over time, or becoming volatile. | trend_direction (improving/declining), margin_change_percentage_points, volatility_level |
| sales_frequency | info → warning | A shift in how often the product sells — e.g. from regular to intermittent demand. | baseline_frequency_days, current_frequency_days, pattern_shift |
| order_size_variability | info → warning | Order sizes becoming more (or less) consistent over time. | baseline_coefficient_variation, current_coefficient_variation, pattern_interpretation |
| seasonality | info | A detected seasonal cycle, an approaching peak, or an entered low season. Requires enough history and enable_seasonality_detection. |
cycle_type (quarterly/annual/…), peak_months, seasonality_strength |
Requires purchases
| type | Severity | What it flags | Key metrics |
|---|---|---|---|
| purchase_pattern_change | info → warning | A change in purchase volume versus the baseline window. | current_avg_quantity, baseline_avg_quantity, volume_change_percentage |
| new_supplier_detected | info → warning | A supplier_id appearing for the first time, with how its cost compares to history. |
new_supplier_id, cost_comparison_percentage, historical_avg_cost |
| supplier_cost_variance | warning | A large cost spread between suppliers for the same product — a sourcing-savings opportunity. | lowest_cost_supplier, highest_cost_supplier, potential_savings |
| purchase_frequency_change | info → warning | A change in how often you reorder. | average_days_between_purchases, recent_days_between_purchases, frequency_change_percentage |
| purchase_cost_volatility | info → warning | Unstable purchase costs across recent orders. | coefficient_of_variation, mean_purchase_cost, volatility_level |
| lead_time_reliability | info → critical | Supplier delivery performance from PO / expected / actual delivery dates. Needs those date fields populated. | average_lead_time_days, on_time_rate_percent, average_delay_days, supplier_metrics |
Requires cost_history
| type | Severity | What it flags | Key metrics |
|---|---|---|---|
| cost_price_change | warning → critical | Average unit cost moving versus baseline, with an estimate of the profitability impact. | current_avg_cost, baseline_avg_cost, cost_change_percentage, profitability_impact |
| recent_cost_change | info → warning | A discrete, recent cost step, echoing your change_reason. |
previous_cost, current_cost, change_date, change_reason |
| cost_volatility | info → warning | Cost fluctuating heavily across the history. | coefficient_of_variation, mean_cost, volatility_level |
Cross-currency (any dataset, needs ≥ 2 distinct currencies)
| type | Severity | What it flags | Key metrics |
|---|---|---|---|
| currency_impact | info → warning | Margin differing across the currencies you sell in, and which currency is weaker/stronger for you. | currency_1, currency_2, margin_difference, weaker_currency |
| currency_pricing_difference | info | The effective selling price (in base currency) differing between currencies. | avg_price_1_base_currency, avg_price_2_base_currency, price_difference_percentage |
| purchase_currency_cost_difference | info | Purchase cost (in base currency) differing between the currencies you buy in. | cost_1, cost_2, difference_percentage, higher_cost_currency |
Metrics vary by detection path. Some types (notably sales_volume_anomaly)
can be produced by several internal methods and expose different metric keys depending on which fired. Always read metrics
defensively — check for a key before using it rather than assuming a fixed shape within a type.
7. Change Tracking
Every analysis is stored against its identifier (and
tenant_context, if given). On the next call for
the same product, the endpoint diffs the new insights against the previous run and returns a
comparison object — so you can surface "what's new since last
time" instead of re-showing everything. It is null only on the very
first analysis.
"comparison": {
"from": "2024-06-01T09:14:22+00:00",
"to": "2024-07-01T08:02:11+00:00",
"result": [
{
"hasSignificantChange": true,
"summary": "Margin fell a further 4.1 points",
"changes": [
{
"class": "margin",
"metric": "current_margin_percent",
"previousValue": 49.8,
"currentValue": 45.7,
"change": -4.1,
"changePercentage": -8.2,
"isSignificant": true,
"severity": "warning",
"description": "Margin continued to compress."
}
]
}
],
"developments": {
"new": [ { "type": "cost_price_change", "class": "cost", "severity": "warning", "title": "Unit cost up 15%" } ],
"changed": [ { "type": "margin_compression", "class": "margin", "severity": "warning", "title": "Margin dropped further" } ],
"resolved": [ { "type": "sales_volume_anomaly", "class": "sales", "severity": "info", "title": "Sales spike normalised" } ],
"unchanged_count": 2,
"summary": "1 new, 1 changed, 1 resolved, 2 unchanged"
}
}
| Field | Type | Description |
|---|---|---|
| from | datetime | ISO 8601 timestamp of the previous analysis. |
| to | datetime | ISO 8601 timestamp of this analysis. |
| result | array | Per-type comparisons that carried a significant change. Each has hasSignificantChange, a summary, and a changes[] list of individual metric movements (with previousValue, currentValue, change, changePercentage). |
| developments | object | A new / changed / resolved breakdown for quick scanning (see below). |
developments
| Field | Type | Meaning |
|---|---|---|
| new | array | Insight types present now but absent last time. Each item is a compact { type, class, severity, title }. |
| changed | array | Types present in both runs whose underlying metrics moved significantly (using each analyser's own significance logic). |
| resolved | array | Types present last time but gone now — the situation cleared up. |
| unchanged_count | integer | How many current insights were essentially the same as before. |
| summary | string | One-line roll-up, e.g. "1 new, 1 changed, 1 resolved, 2 unchanged" or "No developments". |
The change_status stamped on each insight in
result.insights is the per-insight view of the same diff:
new / changed /
unchanged. (resolved
insights are, by definition, no longer in the list — find them under developments.resolved.)
8. Response Metadata
The top-level meta object reports timing and a quick read on
the data you supplied.
| Field | Type | Description |
|---|---|---|
| timing.validation | number (ms) | Time spent validating the request. |
| timing.analysis | number (ms) | Time spent running the analysers. |
| timing.total | number (ms) | End-to-end server time for the request. |
| data_quality.total_data_points | integer | Combined row count across all datasets you sent. |
| data_quality.datasets_provided | array<string> | The non-empty datasets received — subset of ["sales","purchases","cost_history"]. |
9. Batch Analysis
To analyse many products at once, upload them as a batch. Batch analysis runs asynchronously and additionally computes portfolio-level insights that compare products against each other (ABC classification, customer and product-group concentration, price elasticity, weekday performance) — insights the single-product endpoint cannot produce.
Upload Batch
/batch/product-insights
Send a series array of 1–1000 items, where each item is
exactly the same shape as a single /product-insights body.
Every item is validated up front; if any fail you get a 422
with a per-index error map and nothing is queued. On success you get 202 Accepted
with a batch you can poll.
{
"series": [
{
"identifier": "SKU-1",
"sales": [
{"date": "2024-01-15", "quantity_sold": 10, "selling_price": 5.00},
{"date": "2024-02-15", "quantity_sold": 20, "selling_price": 5.00}
]
},
{
"identifier": "SKU-2",
"cost_history": [
{"date": "2024-01-01", "cost_price": 2.00},
{"date": "2024-03-01", "cost_price": 2.60}
]
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| series | array | Yes | 1–1000 product bodies, each shaped like a single-product request. |
| batch | string | No | Your own batch identifier. If omitted, one is generated and returned as identifier. |
Batch Status
/batch/product-insights/{batch}
Poll with the batch identifier. The response carries a status of
pending,
processing,
completed, or
failed, plus the per-product
results once finished. An unknown identifier returns
404.
{
"uuid": "9b2c...",
"identifier": "my-batch-01",
"status": "completed",
"batch_parts": 1,
"results": [ /* one analysis per product, same shape as result.insights */ ],
"started_at": "2024-07-01T08:00:00+00:00",
"completed_at": "2024-07-01T08:00:12+00:00"
}
Batch product-insights is billed like other batch operations. See the Batching page for polling patterns and pricing.
10. Error Responses
HTTP Status Codes
{ error, time_taken_ms }.limit, current_cost, and billing_cycle_end.422 Validation Error
{
"error": "Validation failed",
"errors": {
"identifier": ["The identifier field is required."],
"data": ["At least one dataset (sales, purchases, or cost_history) must be provided."]
},
"time_taken_ms": 1.4
}
400 Bad Request
{
"error": "Could not analyse the supplied data",
"time_taken_ms": 3.2
}
Usage & Cost
Each /product-insights call counts as one
standard API call. The same rate limits
apply, and the total number of data points you can send per request is capped by your plan.