Customer API
Base URL https://api.plunk.io/cx/v1. Versioned,
key-scoped, and self-describing: start every integration at
GET /capabilities.
Authentication
Every request carries an API key. For now, request your keys from Plunk
(talk to us) and tell us which web
origins a publishable key will be called from. Send the key as a bearer token, or in
the X-Api-Key header; both are equivalent.
curl https://api.plunk.io/cx/v1/capabilities \
-H "Authorization: Bearer sk_live_XXXXXXXXXXXX"
# or
curl https://api.plunk.io/cx/v1/capabilities \
-H "X-Api-Key: sk_live_XXXXXXXXXXXX"
| Key class | Prefix | Where it lives | Rules the API enforces |
|---|---|---|---|
| Publishable pk | pk_live_ / pk_test_ |
Browser JavaScript on your website: shippable, public | Origin-locked: requests are accepted only from the web origins registered on the key. Reads are aggregate and non-PII; revenue fields and person-level data never appear. The only writes are event and identify ingest. |
| Secret sk | sk_live_ / sk_test_ |
Your server only | Full scope your account exposes, including revenue-bearing analytics, identity-scoped resources, and sales, order-status and inventory writes. A secret key presented from a browser origin is rejected outright. |
Each key also carries scopes (e.g.
products:read). A key with no explicit scope grant
holds every scope its class allows; a key with a grant is limited to exactly those.
The scope each endpoint requires is listed with the endpoint below, and the pk/sk
pill on each section shows the minimum key class.
Test keys use live data. pk_test_ and
sk_test_ keys are a label, not a sandbox: they read and
write the same account as live keys, and capabilities reports
"mode": "test" for them. Build with a separately named
key, and don't post sales or order status you don't want in your reports.
Versioning. Everything here is /cx/v1.
Within v1 we add endpoints, parameters and response fields but don't remove or rename
them, so ignore fields you don't recognize.
Errors & limits
| Status | Meaning | Body |
|---|---|---|
401 |
Missing or invalid key; a publishable key from an unregistered origin; a secret key from a browser. | Message text with WWW-Authenticate: ApiKey |
403 |
The key is valid but the scope isn't permitted; the resource family isn't
enabled for your account, isn't available to the key's class, or wasn't
granted to the key. A publishable key on a secret-only write (sales, order
status) gets secret_key_required. |
{ "error": "scope_not_permitted", "scope": "…" } |
404 |
The resource doesn't exist; each endpoint returns a named error, e.g.
product_not_found,
dealer_not_found,
contact_not_found. |
{ "error": "…", … } |
400 |
Invalid request: a required field or parameter is missing or unsupported.
The body names the field and what's accepted. An unknown
range token is bad_range. |
{ "error": "…", "note": "…" } |
422 |
A write was understood but nothing in it could be applied, e.g. no SKU in an inventory push matches your catalog, or no row in an order-status report was accepted. | { "error": "…", … } |
429 |
The key has used its daily or monthly quota (see below). | { "error": "quota_exceeded", "message": "…" } |
503 |
An ingest write could not be recorded
(ingest_unavailable) or the customer record was
busy (ingest_busy). Nothing was saved; retry with
backoff. |
{ "accepted": false, "error": "…" } |
Every response is scoped to your account by the key; there is no cross-account surface.
Quotas. Each key allows 1,000 requests a day and
20,000 a month by default. Every authenticated request counts. The
daily count resets at 00:00 UTC and the monthly count on the 1st; past either limit
the API answers 429 quota_exceeded until it resets.
Cache what you can, and contact us
for higher limits.
Date ranges
Endpoints with a {range} segment share one
vocabulary: a named token, or a custom window as
custom_YYYYMMDD_YYYYMMDD
(e.g. custom_20260101_20260630).
An unrecognized token returns 400 with the list of
accepted tokens.
today · yesterday · last_7_days · last_30_days · last_90_days
this_week · last_week · this_month · last_month · this_quarter · last_quarter
ytd · this_year · last_year · all_time
custom_YYYYMMDD_YYYYMMDD
# 400 Bad Request
{
"error": "…",
"code": "bad_range",
"range": "next_month",
"accepted": ["today", "yesterday", "…", "custom_YYYYMMDD_YYYYMMDD"]
}
Exception: /markets reads
pre-aggregated snapshots and accepts its own named set (listed there) without
custom_ windows.
Capabilities pksk
The discovery document and your integration contract. It lists exactly the resource families the presented key can reach: publishable keys never see secret-only families or secret-only endpoints, and families light up as the matching data exists in your account. Fetch it once at startup and code against it, not a static list.
GET/cx/v1/capabilities
{
"tenant": "youraccount",
"tenantType": "retailer", // manufacturer | retailer | vertical
"mode": "live", // live | test, from the key
"aggregationFloor": 5,
"labels": {
"location": "Showroom" // white-label terms, when configured
},
"families": [
{
"name": "products",
"key": "publishable",
"scopes": ["products:read", "recommendations:read"],
"label": null,
"endpoints": [
"GET /cx/v1/products/top/{range}",
"GET /cx/v1/products/{id}",
"GET /cx/v1/products/search",
"GET /cx/v1/products/trending",
"GET /cx/v1/recommendations",
"GET /cx/v1/recommendations/model"
]
},
{
"name": "store-performance",
"key": "secret",
"scopes": ["store-performance:read"],
"label": "Showroom",
"endpoints": [
"GET /cx/v1/stores/{range}",
"GET /cx/v1/stores/{range}/{storeId}"
]
}
]
}
Ingest pksk
The write path into your account: behavioral events from your storefront (publishable
or secret key), and sales, order status and inventory from your server (secret key
only). Scope ingest:write, or
inventory:write for inventory. For events, a 202
with an event id means the event is recorded on the tracking rail; if it cannot be
recorded you get a 503 with
ingest_unavailable. Retry with backoff on 503.
POST/cx/v1/ingest/event
| Field | Type | Notes |
|---|---|---|
type | string | Required. e.g. page_view, product_view, add_to_cart, conversion, or a custom name |
visitorRef | string | Your anonymous visitor reference |
url | string | Page URL |
productId | string | Product the event concerns |
value | number | Monetary value (e.g. conversion amount) |
properties | object | Free-form additional attributes |
Reorder requests. A dealer or rep portal can
send "type": "reorder_request" with
properties: { customerRef, lines: [{ productId, quantity, note }], poNumber, note }.
Because it creates an order request, send it from your server with a secret key; a
publishable key gets 403 secret_key_required. It
records the event as usual and, when your account runs the Plunk Commerce order
desk, also creates an order request draft; the response then adds
orderRequestId and
orderRequestNumber.
curl -X POST https://api.plunk.io/cx/v1/ingest/event \
-H "Authorization: Bearer pk_live_XXXX" -H "Content-Type: application/json" \
-d '{
"type": "product_view",
"visitorRef": "v_84af",
"productId": "L-2750-52"
}'
# 202 Accepted
{
"accepted": true,
"eventId": "9d1c…",
"type": "product_view"
}
POST/cx/v1/ingest/identify
Associates an anonymous visitor with a known identity.
visitorRef is required, plus at least one of
email, hashedEmail,
userId; optional traits
object.
{
"accepted": true,
"eventId": "41be…",
"visitorRef": "v_84af"
}
POST/cx/v1/ingest/sale sk
Record a settled order as real sales data: revenue dashboards, product and
store performance, customer purchase history, and lifecycle stages all read
from it. This is the endpoint a payment integration calls from its webhook
handler; wire it to Stripe's checkout.session.completed
or invoice.paid and your sales arrive as they happen.
Posting the same orderId again replaces that
order's lines, so webhook retries and corrections are safe. The buyer behind
customerRef is created on first sight and linked
to the sale; a first sale advances them to the Customer lifecycle stage
automatically. Secret key only: a publishable key gets
403 secret_key_required.
| Field | Type | Notes |
|---|---|---|
orderId | string | Required. Stable per order (e.g. the Stripe session or invoice id). Reposting it replaces the order's lines. |
customerRef | string | Required. Your stable id for the buyer (e.g. the Stripe customer id). |
lines[].productId | string | Required. Joins your catalog when the id matches; unknown ids still count as revenue. |
lines[].price | number | Required. The extended line total (already multiplied by quantity). Up to 500 lines per order. |
lines[].quantity | integer | Optional; defaults to 1 |
lines[].description | string | Optional line description |
email | string | Optional but recommended; fills the buyer's contact record |
customerName | string | Optional; defaults to email or customerRef |
orderDate | datetime | Optional; defaults to now |
storeId · channel · currency | string | Optional; channel defaults to api |
status | string | Optional; defaults to Shipped (settled). Pass an open-order status only for unsettled orders; they report as backlog, not revenue. |
curl -X POST "https://api.plunk.io/cx/v1/ingest/sale" \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{
"orderId": "cs_a1B2c3",
"customerRef": "cus_R8xT2m",
"email": "buyer@example.com",
"channel": "stripe",
"currency": "USD",
"lines": [
{"productId": "230-00", "description": "Skyline Sofa", "quantity": 2, "price": 2398.00}
]
}'
# 200 OK
{
"accepted": true,
"orderId": "cs_a1B2c3",
"linesWritten": 1,
"customerId": "43f7…",
"customerCreated": true
}
Two posts for the same new buyer at the same moment can return
503 ingest_busy; retry with backoff.
POST/cx/v1/ingest/order-status sk
Your ERP's order status reports: one call per status change, or a batch of up to
500 orders. erpOrderId is the join key to the
order's sales rows, so open and invoiced orders report correctly everywhere.
orderRequest or
poNumber links the first report to a portal order
request. Status words you send are stored as given. Secret key only.
| Field | Type | Notes |
|---|---|---|
orders[].erpOrderId | string | Required. Your ERP order number (the orderId on its sales rows) |
orders[].status | string | Required. e.g. Open, Shipped, Invoiced, Cancelled |
orders[].statusDate | datetime | Optional; defaults to now |
orders[].orderRequest · poNumber · customerRef | string | Optional; link the report to a portal order request |
orders[].lines[] | array | Optional: { productId, ordered, shipped, backordered, eta } |
orders[].shipments[] | array | Optional: { carrier, tracking, shippedDate, lines: [{ productId, qty }] } |
orders[].note | string | Optional |
curl -X POST "https://api.plunk.io/cx/v1/ingest/order-status" \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{"orders":[
{"erpOrderId":"SO-55120","status":"Shipped","poNumber":"PO-7781",
"shipments":[{"carrier":"UPS","tracking":"1Z999…","shippedDate":"2026-10-13",
"lines":[{"productId":"230-00","qty":2}]}]}
]}'
# 200 OK
{
"accepted": 1,
"rejected": 0,
"orders": [
{
"erpOrderId": "SO-55120",
"orderRequestId": "…",
"orderRequestNumber": "OR-1042",
"linked": true,
"from": "Open",
"to": "Shipped",
"salesRowsUpdated": 3,
"error": null
}
]
}
422 invalid_status_report when no order in the call
was accepted (each row's error says why);
404 not_tracked when your account doesn't run the
order desk.
POST/cx/v1/ingest/inventory sk
Push stock levels from your own system. Secret key only, scope
inventory:write: this updates business data and
flips product availability. The same numbers can arrive by CSV feed or the
Shopify connector instead; all three write identically, so use whichever your
stack produces most easily.
| Field | Type | Notes |
|---|---|---|
items[].sku | string | Required. Must match a product id in your catalog; unknown SKUs are skipped and counted in the response. Connect the product feed first. |
items[].source | string | Required. Warehouse or location name. A source containing "transit" counts as sellable inbound stock. |
items[].qtyOnHand | number | Units on hand at that source |
items[].qtyBackordered | number | Optional |
items[].availableDate | date | Optional; when inbound units become sellable |
items[].asOf | datetime | Optional; defaults to now |
curl -X POST "https://api.plunk.io/cx/v1/ingest/inventory" \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{"items":[
{"sku":"230-00","source":"Main Warehouse","qtyOnHand":6,"qtyBackordered":4},
{"sku":"230-00","source":"In Transit","qtyOnHand":30,"availableDate":"2026-10-12"}
]}'
{
"accepted": true,
"itemsWritten": 2,
"skusAggregated": 1,
"availabilityUpdated": 1,
"unknownSkusSkipped": 0,
"sources": ["Main Warehouse", "In Transit"]
}
Each request replaces the sources it carries and leaves other sources alone, so
a snapshot per warehouse is safe to send independently. Up to 5,000 items per
request; page larger snapshots. Availability on your products becomes a
consequence of these numbers: on hand means in stock, inbound-only means
backorder, neither means out of stock. If no SKU in the push matches your
catalog, the response is 422 no_matching_skus.
Products pk
Product intelligence from your sales rail. Scope
products:read.
GET/cx/v1/products/top/{range}
| Param | Default | Notes |
|---|---|---|
groupBy | product | product | category |
category | Filter to one category | |
limit | 20 | 1–100 |
rep | A sales rep code: only that rep's sales (for rep portals) |
Ranked by revenue. Publishable keys receive
rank, units and
orders, with an aggregation floor (rows under 5
orders are suppressed). Secret keys additionally receive
revenue and aov,
with no floor.
curl "https://api.plunk.io/cx/v1/products/top/this_month?limit=3" \
-H "Authorization: Bearer sk_live_XXXX"
{
"range": "this_month",
"groupBy": "product",
"keyClass": "secret",
"count": 3,
"items": [
{
"rank": 1,
"id": "L-2750-52",
"title": "Track-Arm Sofa",
"category": "Sofas",
"units": 41,
"orders": 38,
"revenue": 61250.00,
"aov": 1611.84
}
]
}
GET/cx/v1/products/{id}
The public product card, safe for a storefront. Never includes cost.
{
"id": "L-2750-52",
"title": "Track-Arm Sofa",
"category": "Sofas",
"price": 1899.00,
"salePrice": null,
"image": "https://…",
"link": "https://…",
"availability": "in stock",
"brand": "…"
}
GET/cx/v1/products/search
Semantic search. Matches on meaning rather than keywords, so "something for a small entryway" finds narrow console tables even when none of those words appear in a title. Results are then re-ranked by a blend of relevance, units sold and conversion rate, applied only among products that already matched.
| Param | Default | Notes |
|---|---|---|
q | Required. What to search for, in plain language. | |
filter | Optional keyword filter applied on top of the semantic match. | |
rank | blended | similarity returns pure semantic order, with no commercial weighting. Useful for comparing the two. |
limit | 20 | 1–100 |
curl "https://api.plunk.io/cx/v1/products/search?q=dresser&limit=3" \ -H "Authorization: Bearer pk_live_..."
{
"query": "dresser",
"ranking": "blended",
"count": 3,
"items": [
{
"rank": 1,
"id": "…",
"title": "Carino Dresser - Frost",
"category": "Bedroom",
"link": "https://…",
"imageLink": "https://…",
"price": 1299.00,
"availability": "in stock",
"unitsSold": 24
}
]
}
GET/cx/v1/products/trending
What is getting attention, which is a different question from
/products/top/{range}. That one ranks by
revenue actually booked; this one ranks the tracker's rollups by views,
engagements, conversions or units.
| Param | Default | Notes |
|---|---|---|
range | this_month | Any range token. The default is deliberate: scoped to all_time, "trending" would mean "best ever" rather than "moving now". |
sort | sales_quantity | views, engagements or conversions. |
filter | Optional keyword filter. | |
limit | 20 | 1–100 |
{
"range": "this_month",
"sort": "views",
"count": 20,
"items": [
{ "rank": 1, "id": "…", "title": "Pacifico Dining Chair", "category": "Seating",
"link": "https://…", "imageLink": "https://…", "price": 449.00,
"availability": "in stock" }
]
}
Recommendations pk
"Customers also bought," computed from real purchase history. Two endpoints:
one counts co-occurrence live, the other reads a model rebuilt nightly. Scope
recommendations:read covers both. Returns rank
and co-purchase evidence, never revenue.
GET/cx/v1/recommendations
| Param | Default | Notes |
|---|---|---|
product | With it: products most often in the same order as this one. Without: top sellers (popular fallback). | |
limit | 10 | 1–50 |
{
"basis": "co_purchase",
"product": "L-2750-52",
"count": 10,
"items": [
{
"rank": 1,
"id": "L-2750-OTT",
"title": "Matching Ottoman",
"category": "Ottomans",
"coPurchasedOrders": 19
}
]
}
GET/cx/v1/recommendations/model
The same question answered from a model rebuilt nightly instead of counted live. Prefer this one. The endpoint above counts products in the same order; this counts distinct accounts that bought both over 24 months, weights each pair by how much evidence supports it, and keeps freight, fabric surcharges and card fees out of the results. Both are kept so existing integrations do not change under you.
| Param | Default | Notes |
|---|---|---|
product | What is bought alongside this product. | |
account | What this account should buy next, excluding what it already owns. | |
contact | A person id. Resolves to the account or accounts that contact belongs to and recommends across all of them. Pass exactly one of product, account or contact. | |
limit | 10 | 1–50 |
curl "https://api.plunk.io/cx/v1/recommendations/model?product=5422-22LF&limit=3" \ -H "Authorization: Bearer pk_live_..."
{
"basis": "co_purchase_model",
"product": "5422-22LF",
"account": null,
"count": 3,
"items": [
{
"rank": 1,
"id": "5422-22RF",
"title": "One Arm Curved Loveseat",
"category": "Seating",
"score": 0.594166, // 0-1, higher is stronger
"accounts": 22 // accounts that bought both
}
]
}
An empty items array is a real answer, not an
error. It means the product is new, has too little purchase history to be
confident, or the model has not been built for that catalog yet.
Sectional and modular goods often pair a product with its own mirror half (a left-facing piece with its right-facing twin), so two results can share a title while differing by SKU. That is the strongest signal in the data rather than a duplicate. Render the SKU, not the title alone.
On ?product, score
compares pairs across the whole catalog. On ?account
it is scaled to the strongest hit in that response, so it ranks items within one
answer and is not comparable between accounts.
Reviews pk
Aggregated review ratings from your connected social profiles: trust badges for
your storefront. Scope reviews:read; available once
a social account is connected to your account.
GET/cx/v1/reviews
| Param | Default | Notes |
|---|---|---|
locationRef | Filter to one location's reviews | |
limit | 10 | Recent reviews returned, 1–50 |
{
"locationRef": null,
"averageRating": 4.6,
"reviewCount": 212,
"recent": [
{
"author": "…",
"rating": 5,
"text": "…",
"date": "2026-08-14T00:00:00",
"sentiment": "positive"
}
]
}
Locations pk
Your physical stores and outlets: the feed behind a store locator. Scope
locations:read; available for accounts with a
retail channel.
GET/cx/v1/locations
{
"locations": [
{
"id": "S01",
"name": "Main Street Showroom",
"type": "store",
"city": "Charlotte",
"region": "NC",
"postalCode": "28202",
"lat": 35.2271,
"lng": -80.8431
},
{
"id": "O02",
"name": "Clearance Outlet",
"type": "outlet"
}
]
}
Markets pk
Where your demand comes from: sessions, views and conversions by city, with
coordinates for maps. Scope markets:read.
GET/cx/v1/markets
| Param | Default | Notes |
|---|---|---|
range | this_year | Named snapshots only: today, day, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_7_days |
limit | 25 | 1–200 |
Secret keys additionally receive
conversionValue per market.
{
"range": "this_month",
"keyClass": "publishable",
"count": 25,
"items": [
{
"rank": 1,
"city": "Charlotte",
"region": "NC",
"sessions": 4210,
"uniqueUsers": 3384,
"views": 11840,
"conversions": 96,
"lat": 35.22,
"lng": -80.84
}
]
}
Catalog sk
The full product catalog, cursor-paged, with incremental sync, built for a trade
site or portal that mirrors your line, plus live availability per product. Scopes
catalog:read and
availability:read.
GET/cx/v1/catalog
| Param | Default | Notes |
|---|---|---|
after | Cursor from the previous page's nextCursor | |
category | Filter to one category | |
updatedSince | ISO date-time; only products modified since (incremental sync) | |
limit | 50 | 1–200 |
availableToOrder | false | true drops out-of-stock, discontinued and unavailable products |
{
"count": 50,
"nextCursor": "L-2750-52",
"items": [
{
"id": "…",
"title": "…",
"category": "…",
"price": 1899.00,
"salePrice": null,
"image": "https://…",
"link": "https://…",
"availability": "in stock",
"brand": "…",
"modifiedDate": "2026-08-20T03:10:00"
}
]
}
# Page forward with ?after=nextCursor until nextCursor is null.
GET/cx/v1/products/{id}/availability
Stock, available-to-promise and "ships in N days" for one product, from the same
inventory numbers as the Inventory Planner (CSV feed, Shopify, or
/ingest/inventory). Scope
availability:read.
{
"id": "230-00",
"title": "Skyline Sofa",
"category": "Sofas",
"availability": "in stock", // in stock | backorder | out of stock
"onHand": 6,
"inTransit": 30,
"backordered": 4,
"atp": 32, // on hand + in transit - backordered
"nextAvailable": "2026-10-12",
"leadTimeDays": 90,
"shipsInDays": 0,
"asOf": "2026-10-14T06:00:00Z",
"sources": [
{ "source": "Main Warehouse", "status": "…", "onHand": 6, "backordered": 4, "availableDate": null }
]
}
404 not_tracked until your account has an inventory
snapshot; 404 product_not_found for an unknown id;
404 product_not_tracked when the inventory feed never
lists the product (made-to-order goods).
Visitor sk
Personalization for a resolved contact: which audiences they belong to, for
content targeting and gating on your site. Scope
visitor:read. {reference}
is the numeric contact id in your account.
GET/cx/v1/visitor/{reference}/profile
{
"reference": "18442",
"firstName": "…",
"lastName": "…",
"lifecycleStage": null,
"segmentCount": 3,
"segments": [
{
"id": "…",
"name": "High-intent sofas",
"type": "behavioral"
}
]
}
GET/cx/v1/visitor/{reference}/segments
Just the audience memberships:
{
"reference": "18442",
"count": 3,
"segments": [
{
"id": "…",
"name": "High-intent sofas",
"type": "behavioral"
}
]
}
Dealers sk
Wholesale account analytics for manufacturers: every dealer, ranked and
trended. Scopes dealers:read and
dealer-performance:read; available for accounts
with wholesale sales.
Rep and dealer portals on your own site. Your site signs the visitor
in, works out which rep or dealer account they are, and calls the API from its server
with one secret key, passing ?rep= or
?customerId=. The API applies that as a filter and
nothing else, so a wrong code returns an empty list, never another account's data.
The key never reaches the browser. The same parameters work on
customer sales, contact interest and
products/top, and list endpoints that take
?format=csv return a CSV download.
GET/cx/v1/dealers
The directory with lifetime totals, ranked by revenue. The dealer id is your customer account number.
| Param | Default | Notes |
|---|---|---|
limit | 50 | 1–200 |
rep | Only this rep's accounts (assigned rep or sold-by) | |
format | csv for a download |
{
"count": 50,
"rep": null,
"items": [
{
"id": "D-1042",
"name": "Main Street Interiors",
"city": "Charlotte",
"region": "NC",
"assignedRep": "R07",
"stage": "customer",
"lapsed": false,
"lapseRisk": "watch",
"customerId": "…",
"accountStatus": "active",
"revenue": 412870.00,
"orders": 214,
"units": 1130,
"firstOrder": "2023-02-11T00:00:00",
"lastOrder": "2026-08-19T00:00:00"
}
]
}
GET/cx/v1/dealers/performance/{range}
Ranked by revenue in the window, with the comparison period and a full-period pace.
Scope dealer-performance:read.
| Param | Default | Notes |
|---|---|---|
comparison | previous_period | previous_period (the prior equal-length window) or previous_year |
limit | 50 | 1–200 |
rep | Only this rep's accounts | |
format | csv for a download |
{
"range": "this_quarter",
"comparison": "previous_period",
"rep": null,
"count": 50,
"items": [
{
"id": "D-1042",
"name": "Main Street Interiors",
"city": "Charlotte",
"region": "NC",
"assignedRep": "R07",
"revenue": 88450.00,
"units": 96,
"orders": 41,
"aov": 2157.32,
"revenuePrev": 78690.00,
"deltaPct": 12.4,
"pace": 121400.00, // revenue projected to the end of the window
"lastOrder": "2026-10-02T00:00:00",
"lapseRisk": "healthy"
}
]
}
GET/cx/v1/dealers/lapse-risk
Accounts that have gone quiet compared with their own ordering rhythm: the win-back
call list. Scope dealer-performance:read.
| Param | Default | Notes |
|---|---|---|
band | healthy | watch | at_risk | lapsing; anything else is 400 bad_band | |
rep | Only this rep's accounts | |
minOrders | 3 | Accounts with fewer orders aren't scored |
limit | 100 | |
format | csv for a download |
{
"rep": "R07",
"band": "at_risk",
"minOrders": 3,
"totalAccounts": 182,
"medianCadenceDays": 41.0,
"bands": { "healthy": 120, "watch": 31, "at_risk": 19, "lapsing": 12 },
"revenueAtRisk": 214300.00,
"count": 19,
"items": [
{
"customerId": "D-1042",
"name": "Main Street Interiors",
"region": "NC",
"assignedRep": "R07",
"customerType": "wholesale",
"orders": 14,
"firstOrder": "2023-02-11",
"lastOrder": "2026-07-30",
"trailing12Revenue": 48210.00,
"cadenceDays": 32.5,
"daysSinceLast": 76,
"ratio": 2.34,
"band": "at_risk",
"expectedNextOrder": "2026-08-31"
}
]
}
404 not_applicable when no account has
enough orders to score.
GET/cx/v1/dealers/{id}
One dealer: totals, account profile with bill-to and ship-to, the assigned rep,
contacts, and top products. Contacts carry personal data, so this stays on secret
keys. 404 dealer_not_found for an unknown id.
{
"dealer": {
"id": "D-1042",
"name": "Main Street Interiors",
"revenue": 412870.00,
"orders": 214,
"units": 1130,
"aov": 1929.30,
"firstOrder": "…",
"lastOrder": "…"
},
"profile": {
"accountNumber": "D-1042",
"name": "Main Street Interiors",
"parentAccountNumber": null,
"accountStatus": "active",
"email": "…",
"phone": "…",
"billTo": { "street": "…", "street2": null, "city": "Charlotte", "region": "NC", "postalCode": "28202", "country": "US" },
"shipTo": { "street": "…", "street2": null, "city": "Charlotte", "region": "NC", "postalCode": "28202", "country": "US" },
"customerId": "…",
"stage": "customer",
"lapsed": false,
"lapseRisk": "watch",
"lapseLastOrderDate": "…",
"lapseCadenceDays": 32.5
},
"assignedRep": { "repCode": "R07", "name": "…", "email": "…", "phone": "…", "status": "active" },
"contacts": [
{ "firstName": "…", "lastName": "…", "email": "…", "phone": "…", "role": "buyer", "isPrimary": true, "personId": 18442 }
],
"topProducts": [
{
"id": "…",
"title": "…",
"revenue": 61200.00,
"units": 84
}
]
}
Stores sk
Retail store performance: the retailer twin of dealer analytics. Scope
store-performance:read; available for accounts
with a retail channel.
GET/cx/v1/stores/{range}
The roster with chain totals, percent change vs the prior equal-length period, and each store's share of chain revenue.
{
"range": "this_month",
"chainRevenue": 2410083.00,
"chainOrders": 1181,
"count": 5,
"stores": [
{
"id": "S01",
"name": "Main Street",
"type": "store",
"revenue": 612044.00,
"orders": 292,
"units": 631,
"aov": 2096.04,
"deltaPct": 7.9,
"sharePct": 25.4
}
]
}
GET/cx/v1/stores/{range}/{storeId}
One store's KPIs and top categories.
{
"range": "this_month",
"id": "S01",
"name": "Main Street",
"type": "store",
"kpi": {
"revenue": 612044.00,
"orders": 292,
"units": 631,
"aov": 2096.04
},
"topCategories": [
{
"category": "Sofas",
"revenue": 228412.00,
"units": 121
}
]
}
Audiences sk
Audience performance: membership, engagement, and conversion value per audience.
Scope audience-performance:read. Named ranges read
the latest snapshot; custom_ windows aggregate the
daily series (flows sum, membership takes the window max). Note that
members on the roster counts members active in the
range, while members on the trend is the full
membership on each day.
GET/cx/v1/audiences/{range}
{
"range": "this_month",
"count": 12,
"audiences": [
{
"name": "High-intent sofas",
"audienceId": "…",
"members": 1840,
"conversions": 44,
"conversionValue": 96410.00,
"engagements": 3120,
"engagementValue": 8140.00,
"views": 20110,
"sessions": 6120,
"uniqueUsers": 1710,
"avgTimespan": 84.2
}
]
}
GET/cx/v1/audiences/{range}/{audienceId}/trend
Daily membership counts across the window.
{
"range": "last_90_days",
"audienceId": "…",
"name": "High-intent sofas",
"points": [
{
"date": "2026-06-01",
"members": 1502
}
]
}
Customer sales sk
Identified-customer purchase analytics: summary KPIs, repeat rate, and top
customers by revenue, or one account's orders and products for a portal. Scope
customer-sales:read.
GET/cx/v1/customers/sales/{range}
| Param | Default | Notes |
|---|---|---|
groupBy | summary | summary | order (orders with their lines) | product (what the account buys) |
customerId | One account number: that account's sales only | |
status | booked | booked (everything not cancelled) | invoiced | open | a comma list of your own status words |
q | Order id, SKU or description contains | |
top | 25 | Rows returned: 1–100 for the summary, up to 500 when grouped |
format | csv on the grouped views |
The summary view:
{
"range": "ytd",
"summary": {
"revenue": 1804210.00,
"orders": 902,
"units": 1911,
"customers": 781,
"aov": 2000.23,
"repeatCustomers": 84,
"repeatRatePct": 10.8
},
"topCustomers": [
{
"customerId": "C-2201",
"revenue": 41210.00,
"orders": 6,
"units": 14,
"aov": 6868.33,
"lastOrder": "2026-08-12"
}
]
}
?customerId=D-1042&groupBy=order&status=open,
a dealer's open orders:
{
"range": "this_year",
"groupBy": "order",
"status": "open",
"customerId": "D-1042",
"count": 1,
"items": [
{
"orderId": "SO-55120",
"orderDate": "2026-09-28",
"customerId": "D-1042",
"customerName": "Main Street Interiors",
"status": "Open",
"repCode": "R07",
"storeId": null,
"channel": "erp",
"revenue": 4796.00,
"units": 4,
"lineCount": 2,
"lines": [
{ "productId": "230-00", "title": "Skyline Sofa", "quantity": 2, "price": 2398.00, "status": "Open", "image": "https://…" }
]
}
],
"orderRequests": []
}
groupBy=product returns
items: [{ productId, title, category, image, availability, revenue, units, orders, lastOrder }].
GET/cx/v1/orders/requests
Order requests from the Plunk Commerce order desk (quotes accepted and portal
reorders) waiting for, or already in, your ERP.
?status= (default pending_erp),
?customerId=, ?top=
(default 100). 404 not_tracked when your account doesn't
run the order desk.
{
"status": "pending_erp",
"count": 1,
"items": [
{
"orderRequestId": "…",
"number": "OR-1042",
"quoteNumber": "Q-3310",
"quoteVersion": 2,
"source": "portal_reorder",
"customerId": "D-1042",
"customerName": "Main Street Interiors",
"repCode": "R07",
"poNumber": "PO-7781",
"requestedDate": "2026-10-20",
"shipTo": { … },
"lines": [ … ],
"totalNet": 4796.00,
"erpOrderId": null,
"status": "pending_erp",
"statusDate": "…",
"createdDate": "…"
}
]
}
When your ERP accepts one, report it through
/ingest/order-status with
orderRequest set, and the request links to the order.
Contact interest sk
The behavioral profile behind one contact: what they viewed, did, and bought.
Scope contact-interest:read. PII in, PII out:
server-to-server only.
GET/cx/v1/contacts/interest
| Param | Default | Notes |
|---|---|---|
email | Required unless you pass customerId. The contact's email | |
customerId | An account number instead: every person linked to that account, folded into one profile (adds customerId to the response) | |
range | all_time | Any shared range token |
{
"range": "all_time",
"email": "…",
"lastSeen": "2026-08-21T14:02:11Z",
"impressions": 214,
"engagements": 38,
"conversions": 2,
"conversionValue": 4210.00,
"topPages": [
{
"url": "/sofas/track-arm",
"views": 31
}
],
"topActions": [
{
"type": "form",
"action": "quote_request",
"count": 2
}
]
}
GET/cx/v1/contacts/interest/top/{range}
The most-engaged contacts in the window. ?top=
1–100, default 25.
{
"range": "last_30_days",
"count": 25,
"contacts": [
{
"email": "…",
"engagements": 41,
"lastSeen": "2026-08-22T09:12:44Z",
"conversions": 1,
"conversionValue": 2210.00
}
]
}
Forecasting sk
The demand-forecast rail: projected demand per product, and similarity forecasts
for designs that don't exist yet, built from your own comparable products, and the
restock queue. Scope forecast:read.
GET/cx/v1/forecast/restock
The Inventory Planner's reorder queue: what to order, how much, and by when, from
stock on hand, inbound units, sales velocity and lead time.
?category=, ?limit=
(1–500, default 100), or ?sku= for one product.
{
"category": null,
"windowDays": 14,
"count": 100,
"totalPlanable": 412,
"items": [
{
"sku": "230-00",
"title": "Skyline Sofa",
"category": "Sofas",
"atp": 3,
"backordered": 4,
"inTransit": 0,
"nextAvailable": null,
"unitsPerMonth": 6.5,
"runwayWeeks": 2.0,
"leadTimeDays": 90,
"orderBy": "2026-07-16",
"daysUntilOrderBy": -90,
"status": "past", // past | due | ok
"suggestedQty": 24
}
]
}
404 not_tracked until your account has
an inventory snapshot; 404 product_not_tracked when the
?sku= isn't in the planned set. Titles can repeat across
SKUs, so show the SKU with the title.
GET/cx/v1/forecast/top/{range}
Products ranked by projected views in the window.
?top= 1–100, default 25.
{
"range": "this_month",
"count": 25,
"products": [
{
"productId": "L-2750-52",
"title": "Track-Arm Sofa",
"imageLink": "https://…",
"projectedViews": 4210,
"projectedUniqueUsers": 3180,
"contacts": 112,
"locations": 3,
"places": 14
}
]
}
GET/cx/v1/products/{id}/forecast/{range}
One product's forecast: the prediction series plus projected units by store.
{
"range": "this_quarter",
"productId": "L-2750-52",
"title": "Track-Arm Sofa",
"series": [
{
"date": "2026-09-01",
"grain": "day",
"views": 140,
"uniqueUsers": 104
}
],
"projectedSalesByStore": [
{
"storeId": "S01",
"projectedUnits": 9
}
]
}
GET/cx/v1/forecast/similarity
All similarity forecasts, newest first. Returns
404 similarity_forecast_not_available until the
similarity engine has produced output for your account.
{
"count": 4,
"forecasts": [
{
"id": "…",
"name": "Curved-back lounge concept",
"category": "Chairs",
"status": "complete",
"imageLink": "https://…",
"projectedViews": 1840,
"projectedUniqueUsers": 1420,
"contacts": 40,
"locations": 2,
"places": 8,
"modified": "2026-08-14",
"similarProducts": 6
}
]
}
GET/cx/v1/forecast/similarity/{id}
One similarity forecast in full: the prediction series, the comparable products it was built from (with similarity scores), and projected units by store.
{
"forecast": {
"id": "…",
"name": "…",
"category": "…",
"description": "…",
"status": "complete",
"imageLink": "…",
"projectedViews": 1840
},
"series": [
{
"date": "2026-09-01",
"grain": "day",
"views": 61,
"uniqueUsers": 48
}
],
"similarProducts": [
{
"productId": "…",
"title": "…",
"link": "…",
"imageLink": "…",
"similarityScore": 0.9142
}
],
"projectedSalesByStore": [
{
"storeId": "S01",
"projectedUnits": 4
}
]
}
Enrichment sk
Contact append from your account's enrichment cache: name, address and
demographics for an email you already hold. Scope
enrich:read. PII-bearing: server-to-server only.
POST/cx/v1/enrich/contact
Body: { "email": "…" } (required).
{
"matched": true,
"source": "cache",
"contact": {
"firstName": "…",
"lastName": "…",
"email": "…",
"phone": "…",
"address": "…",
"address2": null,
"city": "…",
"region": "…",
"postalCode": "…",
"country": "US",
"age": 44
}
}
# No cached match:
{
"matched": false,
"email": "…",
"source": "cache",
"note": "…"
}
COM/COL requests sk
Customer's-own-material requests from your COM/COL Visualizer and order desk: the
frame, the shopper, and the full fabric spec, for your own order workflow. Scope
comcol:read; available once your account has
requests. Carries shopper contact details, so secret keys only. To be told the moment
a request arrives, use the COM/COL webhooks.
GET/cx/v1/comcol/requests
Newest first. ?status=,
?limit= (1–200, default 50), and
?cursor= from the previous page's
nextCursor.
{
"requests": [
{
"id": 1182,
"frameSku": "5422-22LF",
"quantity": 1,
"tagFor": "…",
"status": "submitted_for_review",
"customer": { "name": "…", "email": "…", "phone": "…" },
"renderUrl": "https://…",
"poNumber": null,
"submittedAt": "2026-10-13T15:02:11Z",
"createdDate": "2026-10-13T14:51:40Z",
"fabricCount": 2
}
],
"nextCursor": 1181
}
GET/cx/v1/comcol/requests/{id}
One request with each fabric line's spec.
{
"request": { "id": 1182, "frameSku": "5422-22LF", "status": "submitted_for_review", … },
"fabrics": [
{
"lineNo": 1,
"supplier": "…",
"pattern": "…",
"color": "…",
"widthIn": 54.0,
"hRepeatIn": 13.5,
"vRepeatIn": 13.5,
"content": "100% linen",
"silkPct": null,
"backing": null,
"cleaningCode": "S",
"grade": null,
"railroaded": false,
"orientation": "up_the_roll",
"sourceUrl": "https://…",
"swatchUrl": "https://…"
}
]
}
Images sk
Product images from your own systems: create product images, edit them,
approve them, and export them sized for each placement. Scopes
images:read and
images:write.
Calls that create or change an image spend Studio credits from your
account, the same credits the app uses; check the cost first with
/estimate. Long operations return a
jobId to poll.
| Method | Endpoint | What it does |
|---|---|---|
| GET | /cx/v1/images | List images. ?productId= &category= &approval= &q= &$top= |
| GET | /cx/v1/images/{id} | One image with its versions, placements, product and approval state |
| GET | /cx/v1/images/{id}/versions | Every version, newest last, with how each was made |
| GET | /cx/v1/images/{id}/estimate | Credits a set of operations would cost, and your balance. ?ops= &hires= &placements= |
| GET | /cx/v1/images/{id}/exports | Exports made for an image. ?versionId= |
| GET | /cx/v1/images/jobs/{jobId} | Job status: queued | running | done | failed, the new versionId, and the credits spent |
| GET | /cx/v1/images/placements | Placement sizes you can export to |
| GET | /cx/v1/images/categories | Image recipes by product category |
| POST | /cx/v1/images | Make new images. { prompt, productId?, references?, scene?, shotType?, aspect, count, language, placements? }. productId takes your product id or SKU. Spends credits |
| POST | /cx/v1/images/{id}/ops | Edit a version: { versionId, action: background | extend | retouch | recolor | straighten, params }. Spends credits |
| POST | /cx/v1/images/{id}/hires | High-resolution version: { versionId }. Spends credits |
| POST | /cx/v1/images/{id}/approve | { versionId, state: draft | in_review | approved, note? } |
| POST | /cx/v1/images/{id}/export | { versionId, placements, language?, hires: true }. May spend credits for hi-res |
| PUT | /cx/v1/images/{id}/text | Text layers on a version (free) |
| PUT | /cx/v1/images/{id}/placements | Placements to export to (free) |
| POST | /cx/v1/images/upload | Upload your own image: { fileName, contentType, bytes, rights: own | licensed | brand_supplied, rightsNote?, language?, productId? } returns a presigned uploadUrl (15 minutes) and a token |
| POST | /cx/v1/images/upload/{token}/complete | After you PUT the file: registers it. Returns the new image, or the existing one if it's a duplicate |
curl -X POST "https://api.plunk.io/cx/v1/images/1843201/ops" \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{"versionId":"1843215","action":"background","params":{"mode":"scene:studio"}}'
# 202 Accepted
{ "jobId": "88123", "versionId": null }
curl "https://api.plunk.io/cx/v1/images/jobs/88123" -H "Authorization: Bearer sk_live_..."
{ "id": "88123", "status": "done", "versionId": "1843220", "credits": 6, "error": null, … }
402 out_of_credits when your balance can't cover the call;
409 when a version needs approval first or is locked by a
hi-res run; 422 for input it can't use;
429 daily_cap when your account's daily image limit is
reached. Failed jobs refund their credits.