Developers · Plunk Commerce

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 classPrefixWhere it livesRules 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

StatusMeaningBody
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

FieldTypeNotes
typestringRequired. e.g. page_view, product_view, add_to_cart, conversion, or a custom name
visitorRefstringYour anonymous visitor reference
urlstringPage URL
productIdstringProduct the event concerns
valuenumberMonetary value (e.g. conversion amount)
propertiesobjectFree-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.

FieldTypeNotes
orderIdstringRequired. Stable per order (e.g. the Stripe session or invoice id). Reposting it replaces the order's lines.
customerRefstringRequired. Your stable id for the buyer (e.g. the Stripe customer id).
lines[].productIdstringRequired. Joins your catalog when the id matches; unknown ids still count as revenue.
lines[].pricenumberRequired. The extended line total (already multiplied by quantity). Up to 500 lines per order.
lines[].quantityintegerOptional; defaults to 1
lines[].descriptionstringOptional line description
emailstringOptional but recommended; fills the buyer's contact record
customerNamestringOptional; defaults to email or customerRef
orderDatedatetimeOptional; defaults to now
storeId · channel · currencystringOptional; channel defaults to api
statusstringOptional; 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.

FieldTypeNotes
orders[].erpOrderIdstringRequired. Your ERP order number (the orderId on its sales rows)
orders[].statusstringRequired. e.g. Open, Shipped, Invoiced, Cancelled
orders[].statusDatedatetimeOptional; defaults to now
orders[].orderRequest · poNumber · customerRefstringOptional; link the report to a portal order request
orders[].lines[]arrayOptional: { productId, ordered, shipped, backordered, eta }
orders[].shipments[]arrayOptional: { carrier, tracking, shippedDate, lines: [{ productId, qty }] }
orders[].notestringOptional
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.

FieldTypeNotes
items[].skustringRequired. Must match a product id in your catalog; unknown SKUs are skipped and counted in the response. Connect the product feed first.
items[].sourcestringRequired. Warehouse or location name. A source containing "transit" counts as sellable inbound stock.
items[].qtyOnHandnumberUnits on hand at that source
items[].qtyBackorderednumberOptional
items[].availableDatedateOptional; when inbound units become sellable
items[].asOfdatetimeOptional; 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}

ParamDefaultNotes
groupByproductproduct | category
categoryFilter to one category
limit201–100
repA 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.

ParamDefaultNotes
qRequired. What to search for, in plain language.
filterOptional keyword filter applied on top of the semantic match.
rankblendedsimilarity returns pure semantic order, with no commercial weighting. Useful for comparing the two.
limit201–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.

ParamDefaultNotes
rangethis_monthAny range token. The default is deliberate: scoped to all_time, "trending" would mean "best ever" rather than "moving now".
sortsales_quantityviews, engagements or conversions.
filterOptional keyword filter.
limit201–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

ParamDefaultNotes
productWith it: products most often in the same order as this one. Without: top sellers (popular fallback).
limit101–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.

ParamDefaultNotes
productWhat is bought alongside this product.
accountWhat this account should buy next, excluding what it already owns.
contactA 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.
limit101–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

ParamDefaultNotes
locationRefFilter to one location's reviews
limit10Recent 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

ParamDefaultNotes
rangethis_yearNamed snapshots only: today, day, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_7_days
limit251–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

ParamDefaultNotes
afterCursor from the previous page's nextCursor
categoryFilter to one category
updatedSinceISO date-time; only products modified since (incremental sync)
limit501–200
availableToOrderfalsetrue 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.

ParamDefaultNotes
limit501–200
repOnly this rep's accounts (assigned rep or sold-by)
formatcsv 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.

ParamDefaultNotes
comparisonprevious_periodprevious_period (the prior equal-length window) or previous_year
limit501–200
repOnly this rep's accounts
formatcsv 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.

ParamDefaultNotes
bandhealthy | watch | at_risk | lapsing; anything else is 400 bad_band
repOnly this rep's accounts
minOrders3Accounts with fewer orders aren't scored
limit100
formatcsv 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}

ParamDefaultNotes
groupBysummarysummary | order (orders with their lines) | product (what the account buys)
customerIdOne account number: that account's sales only
statusbookedbooked (everything not cancelled) | invoiced | open | a comma list of your own status words
qOrder id, SKU or description contains
top25Rows returned: 1–100 for the summary, up to 500 when grouped
formatcsv 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

ParamDefaultNotes
emailRequired unless you pass customerId. The contact's email
customerIdAn account number instead: every person linked to that account, folded into one profile (adds customerId to the response)
rangeall_timeAny 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.

MethodEndpointWhat it does
GET/cx/v1/imagesList 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}/versionsEvery version, newest last, with how each was made
GET/cx/v1/images/{id}/estimateCredits a set of operations would cost, and your balance. ?ops= &hires= &placements=
GET/cx/v1/images/{id}/exportsExports 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/placementsPlacement sizes you can export to
GET/cx/v1/images/categoriesImage recipes by product category
POST/cx/v1/imagesMake 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}/opsEdit a version: { versionId, action: background | extend | retouch | recolor | straighten, params }. Spends credits
POST/cx/v1/images/{id}/hiresHigh-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}/textText layers on a version (free)
PUT/cx/v1/images/{id}/placementsPlacements to export to (free)
POST/cx/v1/images/uploadUpload 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}/completeAfter 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.

Ready to build?

Included with Plunk Commerce.

Plunk Commerce includes the Customer API and the Shopper App API. Studio Storefront includes the Shopper App API. Request your keys from Plunk. Building something larger, or want us to build it with you?

See pricing Talk to us