# Brand Retail Footprint Source: https://docs.closient.com/agent-skills/brand-retail-footprint Map a brand's retail distribution — which stores carry their products, stock signals, and distribution gaps. Answers "where are my products actually being sold?" Combines the brand's product catalog with the offer graph (who stocks what) to report coverage, stock signals, and distribution gaps. ## When to use * Brand owner asks "which stores carry my products?" * Merchandising: "where do I have distribution gaps?" * Combined with `brand-search-demand` to identify demand/supply mismatch. ## Auth OAuth token with scope `brands:read` and membership in the owning organization. ## Endpoint ``` GET /dashboard/api/v1/brand-analytics/retail-footprint ?organization_id= # optional &brand_id= # optional ®ion= # optional; e.g. "US-TX" &stock_status=in_stock|any # default: any &group_by=store|city|region # default: store &top= # default: 50 ``` ## Response (group\_by=store) ```json theme={null} { "brand_id": "...", "total_stores_carrying": 412, "total_skus_distributed": 38, "unique_stores_by_region": {"US-TX": 68, "US-CA": 112, "CA-BC": 32}, "stores": [ { "store": { "name": "Whole Foods – Austin Domain", "address": "11920 Domain Dr, Austin, TX 78758", "coordinates": {"lat": 30.402, "lon": -97.726} }, "retailer": "Whole Foods Market", "skus_carried": 14, "skus_in_stock": 12, "last_inventory_sync": "2026-04-17T06:12:00Z", "stale_inventory": false } ] } ``` ## Response (group\_by=region) ```json theme={null} { "by_region": [ { "region": "US-TX", "stores": 68, "skus_distributed": 28, "coverage_ratio": 0.74, "top_cities": ["Austin", "Houston", "Dallas"] } ] } ``` ## Data sources * `InStoreOffer` — physical retail offers, with FK to `product`, `location`, `organization` (the retailer), `status`, `quantity_on_hand`, `updated` * `OnlineOffer` — online retailers' offers (optional in response via `include=online`) * `Location` — PostGIS Point for store coordinates * `Brand → Product → Offer` — brand ownership joined in via `Product.brand.organization == ` ## Dimensions available today * **Store-level distribution** — one row per location × product * **Stock state** — from `InStoreOffer.status` and `quantity_on_hand` * **Freshness** — `last_inventory_sync` flags stale data (>48h stale) * **Geographic aggregation** — region / city via `Location` fields ## Not yet available * **Sell-through velocity** — we track inventory snapshots but not POS throughput. Planned via integrations API. * **Distribution gap alerts** — auto-detect where `brand-search-demand` is high but footprint is zero. Planned as a dedicated skill. ## Guidance for agents * **Start with `group_by=region`** for a high-level picture; drill into stores only if the user asks. * **Stale inventory flags** — any store with `last_inventory_sync > 48h ago` should be marked; don't let brand owners overreact to ghost data. * **Online + offline**: by default this skill reports physical retail only. If the user asks about total distribution, call with `include=online` (planned parameter). ## Related skills * `brand-scan-summary` — engagement by product * `brand-search-demand` — where demand lives; cross-reference for gaps # Brand Scan Summary Source: https://docs.closient.com/agent-skills/brand-scan-summary Report a brand's most-scanned products over a time range, with country and device breakdown. Answers questions like "which of my products are agents and consumers scanning most?" Backed by Closient's scan analytics, which record every GS1 Digital Link resolution keyed by product, organization, country (derived from IP), and device class. Pre-aggregated daily so date-range queries are fast. ## When to use * Brand owner asks about engagement with their catalog. * "What's trending in my catalog this month?" * Triage: "which products deserve better data / images / descriptions?" ## Auth Brand-scoped. The caller must hold an OAuth token with scope `brands:read` and membership in the `Organization` that owns the brand(s) in question. Requests without an organization resolve the caller's default organization. ## Endpoint ``` GET /dashboard/api/v1/brand-analytics/scans ?organization_id= # optional; defaults to caller's org &brand_id= # optional; defaults to all brands in org &since= # default: 30 days ago &until= # default: today &top= # default: 20 ``` ## Response ```json theme={null} { "organization_id": "...", "window": {"since": "2026-03-18", "until": "2026-04-17"}, "total_scans": 14823, "billable_scans": 14102, "bot_scans": 721, "top_products": [ { "gtin": "00012345678905", "product_name": "Acme Citric Acid 500g", "brand": "Acme", "scans": 2104, "billable_scans": 2037, "by_country": {"US": 1890, "CA": 147, "GB": 67}, "by_device": {"mobile": 1820, "desktop": 284} } ], "by_country": {"US": 12400, "CA": 1700, "GB": 700, "...": 23}, "by_day": [{"date": "2026-04-17", "scans": 521}, ...] } ``` ## Dimensions available today * **Time** — daily rollups via `ScanDailySummary` * **Country** — derived from scan IP * **City / region** — `ScanEvent.city` / `ScanEvent.region` / `ScanEvent.geo` are populated for any scan arriving through Cloudflare (`CLOUDFLARE_HEADERS_TRUSTED=True` in production), and rolled up into `ScanDailySummary.by_city` / `by_region` for date-range queries. * **Device class** — mobile / desktop / tablet / bot * **Billable vs bot** — bot classifier separates automated traffic ## Not yet available * **Per-hour rollups** — tracked in the raw event stream but not yet exposed via the analytics API. ## Guidance for agents * **Default the window** to the last 30 days unless asked otherwise. Longer windows are fine but require the user to be explicit ("since January"). * **Surface both billable and total** — high bot\_scans on a product can indicate scraper interest worth investigating. * **Pair with `brand-retail-footprint`** when the brand asks "why are my scans lower than expected?" — distribution gaps explain most of it. ## Related skills * `brand-search-demand` — geographic intent at metro/city level * `brand-retail-footprint` — where your products are actually sold # Brand Search Demand Source: https://docs.closient.com/agent-skills/brand-search-demand Report where people are searching for a brand's products, with metro/city-level geographic breakdown. Answers "where is demand for my brand coming from?" at metro / city granularity. Closient's search events carry lat/lon per query, so unlike raw scans (country-level only), search demand can be aggregated to a useful geographic resolution. ## When to use * Brand owner asks "where are people looking for my products?" * Merchandising question: "where should I push for distribution?" * Launch prep: "what metros already have organic demand for this category?" For raw product engagement (scans), pair with `brand-scan-summary`. ## Auth Same as `brand-scan-summary`: OAuth token with scope `brands:read` and membership in the owning organization. ## Endpoint ``` GET /dashboard/api/v1/brand-analytics/search-demand ?organization_id= # optional &brand_id= # optional &since= # default: 30 days ago &until= # default: today &grouping=metro|city|county # default: metro &radius_km= # aggregation bucket radius, default: 25 &top= # default: 10 ``` ## Response ```json theme={null} { "brand_id": "...", "window": {"since": "...", "until": "..."}, "total_queries": 9218, "total_impressions": 41092, "top_areas": [ { "label": "Austin, TX", "center": {"lat": 30.267, "lon": -97.743}, "radius_km": 25, "queries": 412, "unique_users": 287, "impressions": 1924, "click_through_rate": 0.14 } ] } ``` ## Data sources * `SearchEvent` — one row per user query, has `latitude`, `longitude`, `query`, `user`, `created`. * `Impression` — one row per product surfaced in a search result, FK to `SearchEvent` and to the `Organization` that owns the impression. The endpoint filters `Impression.organization_id == `, groups by geohash bucket at the requested radius, and joins back to `SearchEvent` for the query text and user dedupe. ## Dimensions available today * **Geographic** — lat/lon aggregated to metro/city/county level * **Temporal** — date range * **User dedupe** — unique users within the window ## Not yet available * **Conversion** — search → scan → purchase attribution requires linking SearchEvent to downstream ScanEvent to POS data, which we don't yet. * **Query segmentation** — aggregating by the search query text itself (e.g. "top search phrases driving my demand") is planned. ## Guidance for agents * **Address resolution** — `top_areas[*].label` is a human-readable label derived from the bucket center via a reverse-geocode. If the user wants neighborhood-level detail, drop `radius_km` to 5 km. * **Pair with the footprint** — demand in a metro where you have zero stores is the most actionable insight. Cross-reference with `brand-retail-footprint`. ## Related skills * `brand-scan-summary` — raw product engagement (country-level) * `brand-retail-footprint` — where your products are actually available # Build GS1 Digital Link Source: https://docs.closient.com/agent-skills/build-gs1-digital-link Given a GTIN and optional AIs (batch/lot, serial, expiry, etc.), construct a valid canonical GS1 Digital Link URI suitable for resolver hosting, QR encoding, or sharing with downstream systems. The GS1 Digital Link standard (v1.4 / ISO/IEC 18975) encodes Application Identifiers in URL paths instead of barcode element strings, so the *same identifier* can drive a barcode scan, a deep link, and a web resolver. This skill teaches an agent how to compose a correct Digital Link URI from structured input — the inverse of `decode-gs1-ai`. ## When to use * A consumer-facing surface needs a QR code or URL for a product: packaging, shelf-talker, email receipt, branded resolver page. * An ERP / PIM / TMS system has structured product+lot+serial data and needs to emit GS1 Digital Link strings to label printers or partner feeds. * An agent received a raw GTIN and wants to construct the canonical Closient resolver URL to hand back to a user. For the inverse — taking a Digital Link URI and parsing it — see `decode-gs1-ai`. ## Anatomy of a Digital Link ``` https://id.gs1.org/01/09506000134352/10/ABC123/21/SN9999?linkType=gs1:pip └──────┬──────┘ └──┬──────────────┘ └──┬─────┘ └──┬─────┘ └──────┬──────┘ host primary AI + value qualifier qualifier link type ``` | Component | Rules | | - | - | | Host | Any HTTPS host that supports the resolver protocol. `id.gs1.org` is the canonical GS1-managed host; you may use your own domain (e.g. `id.closient.com`, `acme.gtin.one`). | | Primary identifier | Exactly one of `/01/`, `/00/`, `/253/`, `/255/`, `/401/`, `/402/`, `/414/`, `/417/`, `/8003/`, `/8004/`, `/8006/`, `/8010/`, `/8013/`, `/8017/`, `/8018/`. | | Qualifiers | Subsequent `/{AI}/{value}` segments that narrow the primary (e.g. `/10/`, `/17/`, `/21/`). | | Data attributes | Long-tail AIs go in the query string: `?15=271231` for AI 15 best-before. | | `linkType=` | Tells the resolver *what kind of destination* to serve (PIP, product page, instructions, recall, etc.). | ## Step 1 — Validate inputs Before composing anything, normalize: ```python theme={null} from apps.core.types.gtin import GTIN gtin = str(GTIN.from_raw(raw_gtin)) # validates Mod-10 + pads to 14 assert lot is None or len(lot) <= 20 assert serial is None or len(serial) <= 20 # Expiry must be YYMMDD; convert ISO dates first. ``` Reject silently-malformed GTINs at this step rather than building a URI that fails resolution downstream. ## Step 2 — Compose the primary AI GTIN is the common case: ```python theme={null} host = "https://id.gs1.org" path = f"/01/{gtin}" # gtin is 14 digits, zero-padded url = host + path ``` For other primary identifiers: | Primary identifier | Path | | - | - | | GTIN-14 | `/01/` | | SSCC | `/00/` | | GRAI (returnable asset) | `/8003/` | | GIAI (individual asset) | `/8004/` | | GLN (location) | `/414/` | | GSIN (shipment) | `/402/` | | GINC (consignment) | `/401/` | ## Step 3 — Append qualifier AIs (canonical order) GS1 DL §4.4 fixes the order qualifier AIs must appear in: **batch/lot (10) before expiry (17) before serial (21)** when all three are present on a GTIN-anchored URL. The canonical order: ``` /01/{gtin}/22/{cpv}/10/{lot}/21/{serial} ``` Reordering is not a parse error but it *is* a canonicalization error — two different orderings will produce two different canonical URLs and some resolvers cache by canonical form. Closient's resolver normalizes the order on receipt, so an agent emitting a non-canonical URL will get redirected to the canonical one. Better to emit the canonical form from the start: ```python theme={null} def append_qualifier(url: str, ai: str, value: str | None) -> str: return f"{url}/{ai}/{value}" if value else url url = host + f"/01/{gtin}" url = append_qualifier(url, "10", lot) url = append_qualifier(url, "17", expiry_yymmdd) url = append_qualifier(url, "21", serial) ``` ## Step 4 — Encode special characters in values GS1 Digital Link values are URL path segments, so anything outside the unreserved set (`A-Z a-z 0-9 - . _ ~`) must be percent-encoded: ```python theme={null} from urllib.parse import quote safe_serial = quote(serial, safe="") # encode literally everything else url += f"/21/{safe_serial}" ``` In practice, lot/serial characters that show up in real life and need encoding: space (`%20`), `+` (`%2B`), `&` (`%26`), `/` (`%2F`), `?` (`%3F`), `#` (`%23`). Don't pre-escape with `\\` — that's a different encoding. ## Step 5 — Attach data-attribute AIs (query string) AIs that aren't qualifiers go in the query string as `=`: ``` /01/09506000134352?15=271231&3922=12.50 ``` Useful examples: | AI | What it means | Example query param | | - | - | - | | 15 | Best-before date YYMMDD | `15=271231` | | 16 | Sell-by date YYMMDD | `16=271231` | | 11 | Production date YYMMDD | `11=270101` | | 8005 | Price per UoM | `8005=125000` | | 3922 | Price (currency-implied) | `3922=12.50` | Per the spec, query-string AIs should follow data-attribute conventions — not used as primary IDs, never the basis for resolution decisions. ## Step 6 — (Optional) attach `linkType` to drive resolution intent A Digital Link URL can point at *one* identifier but *many* destinations: product info page, recall notice, instructions, video, loyalty enrollment. The `linkType` query string parameter tells the resolver which destination the agent (or end user) wants: ``` https://id.gs1.org/01/09506000134352?linkType=gs1:pip https://id.gs1.org/01/09506000134352?linkType=gs1:recallNotice https://id.gs1.org/01/09506000134352?linkType=gs1:instructions ``` The IANA-registered link-type vocabulary is the source of truth (see `https://www.gs1.org/voc/`). Closient's resolver maps these to hosted-page destinations and the `Resolver` model's `destination_type` column — see `apps/resolver/gs1_link_types.py` for the exhaustive list this codebase recognizes. If you don't set `linkType`, the resolver returns its **default destination** — typically the brand's PIP or the public Closient product page. ## Step 7 — Pick the host | Host | Use it when | | - | - | | `https://id.gs1.org` | You want GS1's official global resolver to handle the URL. | | `https://id.closient.com` | You're publishing through Closient and want our resolver to serve hosted pages, redirects, and analytics. | | `https://.gtin.one` | Closient-managed branded resolver subdomain for a specific brand. | | `https://.example.com` | The brand's own CNAME pointing at Closient. | Any host that implements the GS1 DL resolver protocol will serve the same identifier. The Closient hosted pages live under `id.closient.com` and `*.gtin.one`; the `Brand.preferred_resolver_host` column stores which one to emit for that brand's products. ## Full example Inputs: * GTIN-14: `09506000134352` * Lot: `ABC123` * Expiry: `2030-09-01` (= `300901` YYMMDD) * Serial: `SN9999` * Best-before: `2027-12-31` (= `271231` YYMMDD, AI 15 → query string) * Link type: PIP (product information page) * Host: `https://id.closient.com` Canonical URL: ``` https://id.closient.com/01/09506000134352/10/ABC123/17/300901/21/SN9999?15=271231&linkType=gs1:pip ``` In Python (Closient internal helper): ```python theme={null} from apps.resolver.digital_link_builder import build_digital_link url = build_digital_link( host="https://id.closient.com", gtin="09506000134352", lot="ABC123", expiry="2030-09-01", serial="SN9999", extra_query={"15": "271231"}, link_type="gs1:pip", ) ``` That helper takes care of the AI ordering, percent-encoding, and date formatting in one call. ## Common mistakes * **Wrong qualifier order.** Emitting `/01//21//10/` is parseable but not canonical; the resolver redirects to the canonical ordering and analytics will conflate the two URLs. * **Leaving out the qualifier on a serialized item.** A GTIN+serial URL (`/01//21/`) resolves to that *specific item*; a GTIN-only URL (`/01/`) resolves to the class. Emit the qualifier when you have the data — class-level resolution can't drive item-level experiences like recalls. * **Encoding the AI itself.** AI digits are *path literals* and must NOT be percent-encoded — only the *value* slot is. * **Wrong link-type CURIE.** `linkType=gs1:pip` (correct CURIE) versus `linkType=pip` (bare token, ambiguous) — always emit the namespaced form. Closient's test suite normalizes both, but partner resolvers may not. * **Hardcoding `id.gs1.org` for brand-owned products.** Closient brands have their own preferred resolver host; use that so the brand owns the surface and gets the analytics. * **Not URL-encoding non-ASCII or reserved characters in lot/serial.** A lot of `LOT-2024/Q1` becomes `LOT-2024%2FQ1`. ## What Closient does with the URL Once emitted, the URL can be: 1. **Encoded as a QR code** — Closient's QR rendering service (`apps/products/services/qr_url.py`) takes any Digital Link URL and produces a print-ready QR. See the `Print Digital Link` MCP tool or `POST /products/api/v1/qr/render`. 2. **Hosted by Closient's resolver** — if the URL points at `id.closient.com` or a `*.gtin.one` brand domain, Closient serves the destination (hosted page, redirect, JSON linkset, branded video, etc.) based on `linkType` and the brand's resolver configuration. 3. **Verified for serialized items** — call `GET /resolver/api/v1/verify/01/{gtin}/21/{serial}` to confirm an item is authentic + active. ## Related skills * `decode-gs1-ai` — inverse: take a Digital Link URL apart into AIs. * `resolve-gtin` — once you have the URL, fetch product detail. * `capture-epcis-event` — emit a supply-chain event keyed on the same GTIN + serial. * `closient-api-quickstart` — authenticate before calling `/products/api/v1/qr/render` or `/resolver/api/v1/verify/...`. ## Authoritative references * GS1 Digital Link Standard 1.4 — sections 4 (URI syntax), 5 (canonicalization), 6 (link types). * ISO/IEC 18975:2024 — formalization of GS1 Digital Link. * GS1 link-type vocabulary: `https://www.gs1.org/voc/`. * Closient internal: `apps/resolver/digital_link_builder.py`, `apps/resolver/gs1_link_types.py`, `apps/products/services/qr_url.py`. # Capture EPCIS Event Source: https://docs.closient.com/agent-skills/capture-epcis-event Record a GS1 EPCIS 2.0 supply-chain event (Object / Aggregation / Transaction / Transformation / Association) against the authenticated organization, optionally materializing an FSMA 204 Critical Tracking Event in the same transaction. Closient implements GS1 EPCIS 2.0 (ISO/IEC 19987:2024) end-to-end: events captured by `POST /epcis/api/2.0/capture` are persisted as JSON-LD `EPCISDocument`s and — when the `bizStep` is FSMA 204-relevant — materialized into a `CriticalTrackingEvent` row in the same DB transaction. This skill walks an agent through composing a correct event for each of the five EPCIS event types and posting it. ## When to use * A brand owner or 3PL needs to record a real-world supply-chain observation (harvest, pack, ship, receive, transform). * An EDI 856 ASN or other source needs to be translated into an EPCIS event for FSMA 204 traceability. * An agent is building chain-of-custody for a GTIN + batch and needs to seed the ledger. For querying existing events, see the sibling skill `query-epcis-events`. ## Auth OAuth token (or `X-API-Key` header) where the caller holds `OWNER` or `MANAGER` membership on the target organization. The endpoint will return `403` for `VIEWER` and for users with no membership. The API key format is `csb__`. Rate limit: 300 req/min, 10,000 req/day per key (same as the rest of Closient's APIs). ## Flow ### Step 1 — Choose the event type EPCIS 2.0 defines five event types. Pick the one that matches the real-world observation: | Event type | When to use | Required structural fields | | - | - | - | | `ObjectEvent` | Single-step observation on one or more EPCs (harvest, observation, retail sale). | `epcList` or `quantityList`, `action`. | | `AggregationEvent` | Parent-child container relationship (case → pallet, lot → case). | `parentID`, `childEPCs` (and/or `childQuantityList`), `action`. | | `TransactionEvent` | Linking EPCs to a business transaction (PO, invoice, despatch advice). | `bizTransactionList`, `epcList` and/or `quantityList`, `action`. | | `TransformationEvent` | Inputs → outputs (kill step, cook step, repack). | `inputEPCList`/`inputQuantityList` AND `outputEPCList`/`outputQuantityList`. | | `AssociationEvent` | Sensor / asset attachment to an EPC (cold-chain logger, RFID reader). | `parentID`, `childEPCs`, `action`. | ### Step 2 — Pick a `bizStep` (GS1 CBV 2.0) The full list lives in `apps.epcis.vocabulary.BizStep`. The FSMA 204-relevant subset that materializes a CTE in the same transaction: | `bizStep` | Materialized CTE type | FSMA 204 § | | - | - | - | | `harvesting` | `HARVEST` | § 1.1330(a) — first-receiver of food on the FTL. | | `cooling` | `COOLING` | § 1.1330(b) — first cooling step. | | `packing` | `INITIAL_PACK` | § 1.1330(c) — initial packing. | | `accepting` | `LAND_RECEIVE` | § 1.1335 — first land-based receive of a fishery product. | | `receiving` | `RECEIVE` | § 1.1345 — receiving CTE. | | `shipping` | `SHIP` | § 1.1340 — shipping CTE. | | `TransformationEvent` (any bizStep) | `TRANSFORM` | § 1.1350 — transformation CTE. | Any other `bizStep` is accepted and persisted but does NOT produce a CTE — useful for non-FSMA 204 categories (cosmetics, beauty, household). Submit `bizStep` either as the bare CBV token (`shipping`) or the canonical URI (`urn:epcglobal:cbv:bizstep:shipping`); the server accepts both forms. ### Step 3 — Encode EPCs (GS1 Digital Link preferred) Two EPC encodings are accepted on `epcList` / `outputEPCList` / `inputEPCList`: * **GS1 Digital Link** (preferred): `https://id.gs1.org/01/{gtin14}/10/{lot}` — AI(10) lot is extracted directly from the URI. Supports serialization via `/21/{serial}`. * **EPCIS 1.x SGTIN URN**: `urn:epc:id:sgtin:{company}.{item}.{serial}` — GTIN-14 reconstructed via mod-10 check digit. The URN form does NOT carry AI(10), so the lot must come from the `ilmd` block. For `bizLocation` and `readPoint`, accepted forms are `https://id.gs1.org/414/{gln}` or `urn:epc:id:sgln:{...}`. The server matches against `Location.gln.gln`. ### Step 4 — Compose the EPCIS document and POST ```http theme={null} POST /epcis/api/2.0/capture Authorization: Bearer Content-Type: application/json ``` ```json theme={null} { "@context": ["https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"], "type": "EPCISDocument", "schemaVersion": "2.0", "creationDate": "2026-05-18T14:32:00Z", "epcisBody": { "eventList": [ { "type": "ObjectEvent", "eventTime": "2026-05-18T14:30:00Z", "eventTimeZoneOffset": "+00:00", "epcList": [ "https://id.gs1.org/01/00614141123456/10/LOT-2026-042" ], "action": "OBSERVE", "bizStep": "shipping", "disposition": "in_transit", "readPoint": { "id": "https://id.gs1.org/414/0614141000005" }, "bizLocation": { "id": "https://id.gs1.org/414/0614141000005" } } ] } } ``` Wrap multi-event captures in the same `eventList` array — the server runs them in a single DB transaction (atomic on partial failure). ### Step 5 — Read the response On success (`201 Created`): ```json theme={null} { "captureID": "8c30f2c4-d8e5-4e3a-9b15-6f24a1b8d5fa", "createdEvents": 1 } ``` On per-event validation failure (`400`): `detail` includes the offending `eventID` (or the index in the array). On duplicate event (`409`): the EPCIS event hash (§ 8.4) already exists — fetch the existing one via `GET /epcis/api/2.0/events/{event_id}` rather than retrying. ### Step 6 — Provide an `eventID` for idempotency EPCIS 2.0 events optionally carry an `eventID` (a UUID URN). Submitting the same `eventID` twice produces `409 Conflict` (the hash dedup already enforces this on the wire, but an explicit `eventID` makes retries on flaky networks safe): ```json theme={null} { "eventID": "urn:uuid:8c30f2c4-d8e5-4e3a-9b15-6f24a1b8d5fa", "type": "ObjectEvent", "...": "..." } ``` ### Step 7 — Include `ilmd` for FSMA 204 KDEs The `ilmd` (Instance/Lot Master Data) block carries the FSMA 204 Key Data Elements. The server reads keys in either `cbvmda:` namespace or bare form, case-insensitive. Canonical keys: * `lot_number` — overrides any AI(10) embedded in the EPC. * `harvest_start_date`, `harvest_end_date` — backfilled onto `ProductLot` if the lot is unlocked. * `cooling_date` — written onto the `CriticalTrackingEvent` row. * `expiration_date`, `best_before_date` — backfilled onto `ProductLot`. * `vessel_name`, `vessel_identifier`, `species_description` — copied onto LAND\_RECEIVE CTEs. * `growing_area` — informational; reserved for future structured use. Unknown keys are silently dropped. ```json theme={null} { "type": "ObjectEvent", "bizStep": "harvesting", "ilmd": { "cbvmda:lotNumber": "LOT-2026-042", "cbvmda:harvest_start_date": "2026-05-15", "cbvmda:growing_area": "Salinas Valley, CA" } } ``` ## Required inputs * `type` — one of `ObjectEvent`, `AggregationEvent`, `TransactionEvent`, `TransformationEvent`, `AssociationEvent`. * `eventTime` — RFC 3339 timestamp of the real-world observation. * `eventTimeZoneOffset` — the offset of the recorder (e.g. `+00:00`). * `action` — `ADD`, `OBSERVE`, or `DELETE` (NOT required on `TransformationEvent`). * The structural field(s) for the event type per Step 1. ## Optional inputs * `eventID` — strongly recommended for idempotency. * `bizStep` — required if you want a CTE materialized (Step 2 table). * `disposition` — see `apps.epcis.vocabulary.Disposition`. Notable values: `in_transit`, `in_progress`, `recalled`, `expired`, `damaged`, `destroyed`. * `bizLocation` / `readPoint` — required to resolve a `Location`-keyed CTE. * `bizTransactionList` — for `TransactionEvent`s. * `sourceList` / `destinationList` — `owning_party`, `possessing_party`, `location` source/destination triples. * `ilmd` — KDEs (Step 7). * `sensorElementList` — for cold-chain / temperature data on `AssociationEvent`. ## Output * `201 Created` with `{ "captureID": "", "createdEvents": }`. * `400` — per-event validation failure (offending `eventID`/index in `detail`). * `403` — caller is not `OWNER` / `MANAGER` of the target organization, or the org's billing tier doesn't include EPCIS capture. * `409` — duplicate event hash (idempotent retry succeeded; no new row). * `422` — malformed JSON-LD envelope. * `429` — rate limited. ## Guidance for agents * **Always send the `@context`.** Capture parses the body as JSON-LD; the EPCIS 2.0 context is what tells the server `cbvmda:` keys are KDEs rather than arbitrary metadata. * **Prefer GS1 Digital Link EPCs.** The URN form drops AI(10), forcing the lot to come from `ilmd`. Digital Link keeps the lot inline and is the format the rest of Closient's surfaces (resolver, QR, hosted pages) speak natively. * **One event per real-world action.** Don't batch unrelated harvest events into a single `eventList` — each event is hashed and dedup'd independently, but a transaction-wide validation failure rolls all of them back. Group only when they represent one transactional business operation (e.g., shipping 12 pallets in one despatch). * **Read the `eventTypes` capability first.** A `GET /epcis/api/2.0/eventTypes` (no auth required) returns the set of event types supported. If the environment doesn't expose EPCIS at all, you'll get a 404 — fail fast rather than thrashing on capture. * **Materializer is silent on non-FSMA bizSteps.** Capturing an event with `bizStep=retail_selling` succeeds but produces no CTE. That's expected — don't treat a missing CTE as a failure unless the bizStep is in the FSMA table. * **Lock-aware ILMD backfill.** Once a `ProductLot` has been used in a FSMA-relevant event, its `LOCKED_FIELDS` (lot number, harvest dates, expiration) become immutable. Subsequent captures still produce CTEs, but the ILMD KDEs are dropped on the floor for the lot itself. The captured event preserves them. * **Resolve trading partners and locations up front.** For `RECEIVE` / `SHIP` CTEs the server needs to match a `OWNING_PARTY` source/destination GLN against an existing `TradingPartner.gln.gln` and a `bizLocation` GLN against `Location.gln.gln`. If those rows don't exist, the EPCIS event still captures but the CTE materialization logs an INFO and skips. ## Known gaps (planned) * **No streaming capture.** All events are flushed synchronously on the request. For bulk historical backfills (>1k events), use the management CSV importer at `POST /epcis/api/v1/cte-imports/csv` rather than this endpoint. * **`AssociationEvent` sensor handling is opaque.** Closient persists the `sensorElementList` block verbatim but does not yet decompose it into a structured sensor-reading table; cold-chain alerts are filed but not actionable from query results. * **No partial-success response.** If one event in a batched `eventList` fails validation, the whole transaction rolls back. Per-event success/failure responses would require splitting the capture into per-event sub-transactions; deferred until a customer actually hits the all-or-nothing pain. * **No webhook on materialization.** Materialized CTEs are not pushed back out via webhook today. Compliance dashboards refresh on demand. ## Related skills * `query-epcis-events` — read-side counterpart for chain-of-custody reconstruction. * `check-fsma-204-readiness` — validate that captured events have the KDEs needed for FSMA 204 export. * `manage-recall` — uses captured `disposition=recalled` events as ground truth for "what is currently under recall". * `check-recalls` — consumer side; reads `disposition=recalled` to surface alerts. # Check FSMA 204 Readiness Source: https://docs.closient.com/agent-skills/check-fsma-204-readiness Audit one or more GTINs for FSMA 204 (FDA Food Traceability Rule, 21 CFR § 1.1320) Key Data Element coverage by walking the captured EPCIS event ledger and the materialized Critical Tracking Event table for the authenticated organization. FSMA 204 (the FDA Food Traceability Rule, 21 CFR § 1.1320) requires that any entity handling a food on the Food Traceability List (FTL) maintain Key Data Elements (KDEs) for each Critical Tracking Event (CTE) — harvest, cooling, initial pack, first land-receive, shipping, receiving, transformation. § 1.1455(c) further requires those records be furnished to FDA within 24 hours of request. This skill walks an agent through auditing one or more GTINs for KDE coverage by reading the EPCIS event ledger and the materialized CTE rows that Closient produces automatically when FSMA-relevant events are captured. ## When to use * A brand owner needs to know "are we FSMA 204-compliant for batch X?" before the Jan 20, 2026 enforcement date — and afterward, every day. * Before issuing a recall, audit which lots have enough KDE coverage to construct the FDA Sortable Spreadsheet export. * A 3PL onboarding a new shipper needs to gap-analyze their KDE posture before contracting. * Continuous-readiness dashboards. For the FDA Sortable Spreadsheet *export* (the artifact you furnish under § 1.1455(c)), use the EPCIS endpoint `GET /epcis/api/2.0/traceability/lots/{lot_id}/fda-export`. This skill is the gap-analysis step that runs *before* the export to flag missing KDEs. For a **historical snapshot** — "what KDEs did we have on file as of a past date", the question an FDA investigator or auditor actually asks during a § 1.1455(c) request, not "what do our records show right now" — use `GET /epcis/api/2.0/traceability/lots/{lot_id}/as-of?as_of={timestamp}` (C-5400). It replays the same immutable CriticalTrackingEvent ledger filtered to rows *captured* at or before `as_of`, never by the underlying event's own `event_date` — a CTE recorded (entered into the system) after the audit date is correctly excluded from the snapshot even if the event itself occurred earlier. ## Auth OAuth token (or `X-API-Key`) with `OWNER` or `MANAGER` membership on the organization that owns the GTINs being audited. ## Flow **Note (C-5399)**: for a single lot, prefer the dedicated readiness endpoint over composing the steps below by hand: ```http theme={null} GET /epcis/api/2.0/traceability/lots/{lot_id}/fsma-204-readiness ``` It runs Step 3's KDE-completeness rubric server-side, in one call, using the corrected logic (C-5398): a trading-partner GLN captured but never resolved to a registered `TradingPartner` counts that CTE incomplete, not satisfied. See **Output** below for its response shape. It does **not** do Step 2's food-category-aware CTE-presence checklist or Step 4's two-dimensional `(cte_coverage_pct + kde_coverage_pct) / 2` score — those still require composing Steps 1-2 by hand (see **Known gaps**). Steps 1-4 below describe the full manual audit, and remain the only path for resolving GTINs to lots (Step 1) and for bulk/multi-lot audits (no bulk endpoint exists yet). ### Step 1 — Resolve GTINs to lots For each GTIN you want to audit, list the lots that have any EPCIS activity: ```http theme={null} GET /epcis/api/2.0/events?MATCH_anyEPC=https%3A%2F%2Fid.gs1.org%2F01%2F{gtin14} ``` Use the prefix match (no trailing `/10/{lot}`) — the server matches EPC URIs by exact string, so a prefix-trimmed URI captures every lot under the GTIN. Collect distinct `epcList` values from the response, parse the AI(10) lot segment off each one. Alternatively, pull lots directly from the products API: `GET /products/api/v1/products/{gtin}/lots` (if exposed for the org). ### Step 2 — Audit CTE coverage per lot For each lot, ask the EPCIS ledger which bizSteps were observed: ```http theme={null} GET /epcis/api/2.0/events?MATCH_epc=https%3A%2F%2Fid.gs1.org%2F01%2F{gtin14}%2F10%2F{lot} ``` Pull the distinct `bizStep` values from the response. Compare against the FSMA 204 CTE set expected for the product's food category. The generic checklist: | Food category | Required CTEs (per § 1.1320 subpart and FTL row) | | - | - | | Fresh produce (FTL Cat 1) | `harvesting` → `cooling` → `packing` → `shipping` → `receiving` | | RTE foods (FTL Cat 9) | `packing` (initial) → `shipping` → `receiving` | | Fishery products (FTL Cat 17) | `accepting` (LAND\_RECEIVE) → `shipping` → `receiving` | | Cheese / dairy (FTL Cat 6) | `packing` → `shipping` → `receiving` | | Eggs, in shell (FTL Cat 7) | `packing` → `shipping` → `receiving` | | Generic processed | `shipping` → `receiving` (no INITIAL\_PACK if the lot wasn't packed by this org) | A lot missing a required bizStep has a KDE gap. ### Step 3 — Audit KDE completeness per CTE For each captured EPCIS event in the lot's chain, verify that the KDEs FSMA 204 expects for that CTE type are present. Required KDEs by CTE type (per FDA Sortable Spreadsheet template): | CTE type | Required KDEs | | - | - | | `HARVEST` | Lot code, growing area, harvest start date, harvest end date, location (GLN). | | `COOLING` | Lot code, cooling date, location (GLN), reference document (PO/BOL). | | `INITIAL_PACK` | Lot code, packing date, quantity, location (GLN), reference document. | | `LAND_RECEIVE` | Lot code, vessel name, vessel identifier (USCG), species, landing date, location (GLN). | | `SHIP` | Lot code, quantity, ship date, ship-to location (GLN), trading partner. | | `RECEIVE` | Lot code, quantity, receive date, ship-from location (GLN), trading partner. | | `TRANSFORM` | Input lots, output lots, transform date, location (GLN), reference document. | Closient extracts these KDEs from the EPCIS `ilmd` block (Step 7 of `capture-epcis-event`) and the resolved trading-partner / location records. An event with `bizStep=harvesting` but no `cbvmda:harvest_start_date` in `ilmd` is a KDE gap. ### Step 4 — Compose a readiness score The `fsma-204-readiness` endpoint (see the Note above) computes Step 3's KDE-completeness rubric for you, per lot. What follows is the fuller two-dimensional rubric an agent doing a manual, food-category-aware audit (Step 2 + Step 3 together) would compose — the endpoint does not (yet) implement the CTE-presence half of it: ``` score = (cte_coverage_pct + kde_coverage_pct) / 2 ``` Where: * `cte_coverage_pct` = (CTEs observed for the lot ∩ CTEs required for the food category) / required. * `kde_coverage_pct` = (KDE fields populated across captured CTEs) / expected KDE fields across those CTE types. A lot at \< 80% is unlikely to satisfy a § 1.1455(c) request. ## Required inputs * `gtin` — GTIN-8 / 12 / 13 / 14 (server zero-pads shorter forms). * `lot` (when scoping to a specific batch). ## Optional inputs * `food_category` — to pick the correct CTE checklist. Closient infers this from `Product.product_category` when it's set. * `date_range` — bound the audit to a recent window (e.g. last 365 days of shipments). ## Output ### `fsma-204-readiness` endpoint (per lot, C-5399) ```json theme={null} { "lot_id": "aQ4nF7zG3kmRtXp9LcWyVH", "lot_identifier": "LOT-2026-042", "generated_at": "2026-09-07T00:00:00+00:00", "cte_count": 3, "complete_count": 2, "incomplete_count": 1, "score": 0.6667, "missing_kdes": { "SHIP": ["missing_immediate_subsequent_recipient"] }, "events": [ { "event_type": "SHIP", "event_date": "2026-08-20T00:00:00+00:00", "lot_identifier": "LOT-2026-042", "complete": false, "issues": ["missing_immediate_subsequent_recipient"] } ] } ``` `score` is `complete_count / cte_count` — the fraction of CTEs currently on file for the lot that pass the corrected KDE-completeness rubric. A lot with zero CTEs scores `0.0`, not `null` (see **Guidance for agents** below). `issues` values are one or more of `missing_immediate_subsequent_recipient` (SHIP), `missing_immediate_previous_source` (RECEIVE), and `missing_reference_document` (any CTE type). This is **KDE completeness only** — it does not include `cte_coverage_pct` (whether the lot has every CTE type its food category requires) or a `missing_ctes` list of entirely-absent CTE types. That half of the two-dimensional rubric (Step 4 above) is still a manual composition of Steps 1-2 until the food-category checklist moves server-side (**Known gaps**). ### Manual composition (Steps 1-4, multi-GTIN or CTE-presence audits) A list of structured rows, one per (GTIN, lot): ```json theme={null} [ { "gtin": "00614141123456", "lot": "LOT-2026-042", "food_category": "leafy_greens", "cte_coverage_pct": 0.83, "kde_coverage_pct": 0.71, "score": 0.77, "missing_ctes": ["COOLING"], "missing_kdes": { "HARVEST": ["growing_area"], "INITIAL_PACK": ["reference_document"] }, "links": { "capture_missing": "https://www.closient.com/compliance/lots//capture", "fda_export": "/epcis/api/2.0/traceability/lots//fda-export" } } ] ``` ## Guidance for agents * **Run the audit on the materialized CTE table, not raw events, where you can.** The `CriticalTrackingEvent` model (see `apps.epcis.models`) is the authoritative source of CTE rows — EPCIS events that didn't produce a CTE (because the bizStep isn't FSMA-relevant) are noise for this audit. The `/traceability/lots/{lot_id}/fda-export` endpoint already does this filtering; reading its 202-pending response tells you which CTEs exist for the lot. * **Don't conflate "lot exists" with "lot is ready".** A lot with zero EPCIS events is `0.0` readiness, not "no data". The product exists in the catalog; FSMA 204 requires CTEs. * **Lock-state matters.** Once a `ProductLot` has been used in a CTE, its `LOCKED_FIELDS` (lot code, harvest dates, expiration) are immutable. Don't propose a fix that requires editing those fields — instead, the agent should surface a "capture a corrective event with the missing KDE in ILMD" workflow (calls `capture-epcis-event` with the right bizStep + ilmd block). * **Audit BY ORGANIZATION's role in the chain.** A brand owner who only packs (not harvests) is not on the hook for HARVEST KDEs — those belong to the grower upstream. The CTE checklist in Step 2 is conditional on the org's role; for a packer-only, only `INITIAL_PACK` + `SHIP` are owed. * **Surface the fix path, not just the gap.** A missing `growing_area` KDE on a HARVEST event is fixable by capturing a corrective `bizStep=harvesting` event with the right `cbvmda:growing_area` — hand the agent the exact endpoint and JSON shape it needs. * **Don't recompute on every page render for a manual (Step 1-4) multi-GTIN audit.** The `fsma-204-readiness` endpoint (per lot) is cheap enough to call directly — it runs one query pass over the lot's own CTEs, no caching layer. A bulk, multi-GTIN audit still costs ≥ 2 EPCIS queries per lot until the bulk endpoint below ships; batch those. ## Known gaps (planned) * **No food-category-aware CTE checklist on the server.** The expected-CTE-by-FTL-category table (Step 2) lives in this skill doc rather than as a server-side rule, so `fsma-204-readiness` cannot tell you a lot is missing an entire CTE type (e.g. no COOLING event at all) — only that the CTEs actually on file have incomplete KDEs. When the checklist moves server-side, `fsma-204-readiness` will gain a `missing_ctes` field and the skill will reference the checklist by URL instead of restating the table. * **No bulk-GTIN readiness scan.** Agents auditing dozens of GTINs have to fan out queries, one `fsma-204-readiness` call per lot. A bulk endpoint (`POST /compliance/api/v1/fsma204-readiness/scan` taking a GTIN list) is planned for the FSMA dashboard refactor. ## Related skills * `capture-epcis-event` — the corrective-action surface; agents capture missing-KDE events to close gaps surfaced by this audit. * `query-epcis-events` — the underlying read behind the manual Step 1-4 audit and behind `fsma-204-readiness` itself. * `manage-recall` — pre-recall sanity check; only issue recalls on lots whose readiness score is high enough to compose a defensible FDA Sortable Spreadsheet. * `check-recalls` — consumer side of recalls; orthogonal to readiness but reads the same CTE ledger. # Check Product Availability Source: https://docs.closient.com/agent-skills/check-product-availability Given a known GTIN and a location, find nearby stores stocking that product with stock status and pricing. For when the user already knows what they want and needs to know where it's actually in stock. ## When to use * Agent has a specific GTIN (from barcode, `resolve-gtin`, or `local-product-search`). * User asks "where can I actually buy this *today*?" Don't use this for discovery — use `local-product-search` instead. ## Current flow (session-based search) Closient's search session takes a GTIN constraint and returns availability hits grouped by store. ``` POST /search/api/v1/search/session Content-Type: application/json { "query": "gtin:00012345678905", "latitude": 48.4284, "longitude": -123.3656, "radius_km": 25, "in_stock": true } ``` ## Response Each hit has: * `store` — name, address, distance\_km * `offer` — price, stock\_status (`in_stock` / `low_stock` / `out_of_stock` / `unknown`), last\_checked\_at ## Typical flow 1. Start with `local-product-search` to find a candidate product → get `gtin`. 2. Use this skill to check where to actually buy it near the user. 3. Optionally use `resolve-gtin` for deeper product detail. ## Planned (not yet shipped) `GET /retailers/api/v1/products/{gtin}/availability?lat=...&lon=...&radius_km=...` — a direct geo-distance endpoint that skips the session. Use the session approach above until that lands. ## Related skills * `local-product-search` — start here if the user doesn't have a GTIN * `resolve-gtin` — get product detail for the GTIN in the hits * `check-recalls` — confirm the product isn't under recall before recommending # Check Recalls Source: https://docs.closient.com/agent-skills/check-recalls Check recalls and safety notices for a product by GTIN, optionally narrowed to a specific batch/lot (AI 10), with severity and source agency. Surface active recalls, safety notices, and health advisories for a product. Closient aggregates recalls from FDA (food, drug, device), FSIS (meat/poultry), CPSC (consumer products), Health Canada, and CFIA (Canadian food), keyed by GTIN with optional batch/lot narrowing. ## When to use * User scans a product and asks "is this safe?" * Before recommending a product in any other skill (`find-alternative`, `plan-shopping-trip`, etc.), do a recall check first. * Agent monitoring a user's recently-purchased items. * When the scanned packaging encodes a batch/lot in GS1 AI 10 (a GS1 Digital Link of the form `/01/{gtin}/10/{batch}`), use the batch-specific endpoint so the answer is "is *this lot* under recall?" rather than "are any batches of this GTIN recalled?". ## Endpoints ### GTIN-level lookup ``` GET /compliance/api/v1/products/{gtin}/recalls ?status=ONGOING|COMPLETED|TERMINATED|UNKNOWN &severity=CLASS_I|CLASS_II|CLASS_III &source_system=FDA|FSIS|CPSC|HEALTH_CANADA|CFIA ``` Returns every open recall for the GTIN. Defaults to `status=ONGOING` to show live notices. ### Batch/lot lookup (AI 10) ``` GET /compliance/api/v1/products/{gtin}/batches/{batch}/recalls ?status=ONGOING|COMPLETED|TERMINATED|UNKNOWN &severity=CLASS_I|CLASS_II|CLASS_III &source_system=FDA|FSIS|CPSC|HEALTH_CANADA|CFIA ``` Returns only the recalls whose `lot_codes` field matches the supplied `batch`. Matching is case-insensitive and supports four patterns: * **Exact**: `batch` equals an entry in `lot_codes` (legacy strings, free-text token, or `exacts` array in the structured form). * **Range**: `lot_codes` includes a numeric or alphanumeric range like `"lots 20261001 through 20261015"` (free-text) or `{"ranges": [["20261001", "20261015"]]}` (structured). The scanned batch must be the same length as the endpoints and fall inside the inclusive range. * **Wildcard**: `lot_codes` includes a trailing-`*` pattern like `"LOT-2026-*"` or `{"wildcards": ["LOT-2026-*"]}`. Any batch whose case-insensitive prefix matches. * **All-lots**: a recall with an empty `lot_codes` field did not narrow to a subset of batches — every lot is considered affected and the recall is always returned. ## Typical consumer scan flow 1. Consumer scans a packaging QR encoding a GS1 Digital Link with AI 10: ``` https://id.closient.com/01/00012345678905/10/BATCH-2026-042 ``` 2. Agent extracts `gtin=00012345678905`, `batch=BATCH-2026-042`. 3. Agent calls: ``` GET /compliance/api/v1/products/00012345678905/batches/BATCH-2026-042/recalls ``` 4. If the response is empty, the specific lot is not under recall. Surface that explicitly ("No active recalls for lot BATCH-2026-042") rather than silently proceeding. 5. If the response contains recalls, surface them prominently — Class I first. Direct scans of `/01/{gtin}/10/{batch}` in a browser hit the resolver, which routes the consumer through the recall interstitial when an open recall affects that specific batch (the same lot-matching logic powers both surfaces). The API endpoint above is for agents that want the structured response without the redirect dance. ## Response Array of recall records. Each has: * `id` — Closient short-UUID * `title` — short human description * `status` — ONGOING / COMPLETED / TERMINATED / UNKNOWN * `severity` — CLASS\_I (most serious) / CLASS\_II / CLASS\_III * `source_system` — issuing agency * `description` — free-text narrative * `hazard` — what harm exposure could cause * `remedy` — what consumers should do * `public_url` — authoritative regulator URL * `company_name` — recalling firm * `recall_date` — when the regulator initiated the recall * `jurisdictions` — ISO 3166-1 alpha-2 country codes * `created` — when Closient first ingested the record ## Severity at a glance * **CLASS\_I** — reasonable probability of serious adverse health consequences or death. Surface prominently. * **CLASS\_II** — temporary or medically reversible health effects. * **CLASS\_III** — unlikely to cause adverse health effects; usually labeling violations. ## Empty array An empty response means no recall record matched this GTIN (or, for the batch endpoint, that specific lot). Always worth saying explicitly ("No active recalls") rather than silently proceeding. It is **not**, on its own, a clearance: these endpoints report what is in the recall dataset, and cannot tell you whether every regulator that covers the product has actually delivered recently. The resolver's `safety.recallStatus` field (see `resolve-gtin`) does — it returns `indeterminate` rather than `none` when a governing source is dark. Read it alongside an empty array before telling a user a product is clear. ## Related skills * `resolve-gtin` — get the product detail to display alongside the recall * `find-alternative` — if recalled, recommend a substitute # Claim Brand Source: https://docs.closient.com/agent-skills/claim-brand Walk a brand owner through claiming an existing unclaimed Brand on Closient or creating + claiming a new one, then linking it to their Organization. Closient indexes brands from many sources (Open Food Facts, Open Beauty Facts, USDA, manual entry, etc.). A brand with `organization=null` is **unclaimed** — crowd-sourced metadata waiting for the legitimate brand owner to claim it and start managing the record. This skill walks an authenticated brand-owner through the claim flow end-to-end. ## When to use * A brand owner wants to manage their products on Closient. * The agent needs to attach an `Organization` to an existing `Brand` record (so analytics, product edits, and resolver routing work). * A retailer's onboarding flow turned up an unclaimed brand the retailer also owns. For pure brand *creation* (no pre-existing record on Closient), the same endpoint creates the brand already linked to the caller's organization — see Step 2 below. ## Auth OAuth token (or API key) with `brands:write` scope. The caller must hold `OWNER` or `MANAGER` role on the `Organization` that will own the claimed brand. A `VIEWER` cannot claim. ## Flow ### Step 1 — Discover candidate unclaimed brands ``` GET /brands/api/v1/brands?name= ``` Returns paginated brands. Unclaimed brands have `organization=null` in the source data but are NOT filtered by the API today (see **Known gaps** below). The agent should: 1. Issue the search. 2. Pull the full brand record for each candidate via `GET /brands/api/v1/brands/{id}` to see `organization_id`. 3. Present the user with the matches that are unclaimed. ### Step 2 — Create-and-claim (the supported path today) ``` POST /brands/api/v1/brands Content-Type: application/json { "organization_id": "", "name": "Acme Snacks", "website": "https://acmesnacks.com", "description": "Premium trail mix and snack foods." } ``` If no existing unclaimed record matches, this creates the brand already linked to the caller's organization (which is the destination state of a claim anyway). The endpoint rejects duplicates within the same organization (`409`). ### Step 3 — Enrich the claimed brand Once the brand exists and is linked, the agent should immediately: ``` PATCH /brands/api/v1/brands/{id} { "instagram": "...", "facebook": "...", "website": "...", "description": "..." } ``` Social links + description materially improve brand-analytics signal and the brand's surface on the Closient resolver pages. ### Step 4 — Upload a logo (optional but recommended) ``` POST /brands/api/v1/brands/{id}/logo Content-Type: multipart/form-data logo: ``` PNG or SVG, max 2 MB. The logo shows on resolver hosted pages and in search results. ### Step 5 — Verify the link ``` GET /brands/api/v1/brands/{id} ``` The `organization` field should reflect the caller's organization. Once set, organization cannot be cleared (`Brand.organization` is a one-way claim per `apps/brands/models.py`). ### Step 6 — Verify ownership of the brand's products Each claimed GTIN carries its own verification status. For every product the user cares about: ``` GET /products/api/v1/products/{gtin}/verification ``` This returns `claim_verification_status`, the evidence already filed with its review state and expiry, `promotion_rules` (how many of your organization's approved pieces of each strength are still `missing` under each rule — any one rule met qualifies), and the `domain_verification` token and targets. To move toward Verified: * `POST /products/api/v1/products/{gtin}/verification/evidence` with `{"evidence_type": "gs1_certificate", "file_base64": "..."}` or `{"evidence_type": "gepir_lookup", "notes": ""}`. It is stored `pending` until a Closient reviewer approves it. Files are at most 1.5 MiB decoded over the API and 150 KiB over MCP. * `POST .../verification/dns-check` after the user publishes the token as a TXT record at `domain_verification.dns_record_name`. * `POST .../verification/http-check` only when `domain_verification.http_enabled` is true; otherwise it returns 409. Over MCP the same calls are `get_claim_verification`, `submit_claim_evidence`, `check_claim_dns` and `check_claim_http`. Nothing here makes a claim Verified by itself; tell the user their evidence is awaiting review rather than that the claim is verified. All three writes are rate-limited, so don't retry a DNS check in a loop while the record propagates. ## Required inputs * `organization_id` — the caller's organization (claim destination). * `name` — brand display name. ## Optional inputs * `website`, `description` * Social links (`instagram`, `facebook`, `twitter`, `linkedin`, `youtube`, `tiktok`, `pinterest`, `threads`) — via `PATCH`. * `logo` — via `POST /brands/{id}/logo`. ## Output * `verification_status` — currently always `linked` once `POST` returns `201` (no manual review gate today; see Known gaps). * `brand_id` — the short ID of the linked brand. * `next_steps` — recommend the user upload a logo, fill social links, and start adding products to their brand. ## Guidance for agents * **Search first, create second.** Always run the discovery query (Step 1) before creating, even if the brand is well-known — the user may have a Closient-side record they don't know about. Duplicate brands per organization are blocked (`409`) but cross-org duplicates are not, so this is the only protection against accidentally creating a second crowd-sourced copy. * **Confirm the organization** before posting. The caller may belong to multiple orgs; ask which one owns the brand. The `account/whoami` endpoint lists the caller's organizations. Over an API key that is always exactly one — a key belongs to a single organization and acts as its own identity there — so there is nothing to disambiguate. * **One-way claim.** Once `organization` is set, it cannot be cleared via the API (the model raises on attempts to null it). Surface this to the user before the `POST`. * **Don't loop on duplicate errors.** A `409` means the brand already exists for that organization — fetch and patch instead of retrying. ## Known gaps (planned) * **No "claim existing unclaimed brand" endpoint.** Today the `BrandUpdateIn` schema does not accept `organization`, so the only way to *attach an organization* to an existing unclaimed record is via the `POST` (which creates a new linked record). A dedicated `POST /brands/{id}/claim` is planned so an existing crowd-sourced Brand can be claimed without duplicating it. Until then, the agent should flag candidate unclaimed records to the human owner for reconciliation. * **No `?unclaimed=true` filter.** The list endpoint accepts `name` and `organization_id` filters but not a "show me only unclaimed" query. Agents must fetch detail records to determine claim state. * **Linking a brand is not verified; claiming its products is.** The brand link above is accepted on the strength of the OAuth scope alone. Ownership is proven per product instead, through the claim verification API in Step 6. Only staff-reviewed evidence (a GS1 certificate or a Verified by GS1 registry record) earns Verified; a DNS or HTTP domain check is recorded for the reviewer but does not verify on its own. * **No brand-merge.** If a duplicate linked brand was accidentally created, there is no API to merge it into the canonical record. ## Related skills * `onboard-retailer` — the parallel flow for retailer Organizations connecting their POS. * `brand-scan-summary` — once claimed, see engagement on your products. * `brand-retail-footprint` — once claimed, see distribution coverage. * `brand-search-demand` — once claimed, see geographic demand. # Closient API Quickstart Source: https://docs.closient.com/agent-skills/closient-api-quickstart Zero-to-first-call quickstart for any agent integrating with Closient — discover APIs, register an OAuth client (RFC 7591) or get an API key, complete an authorization_code + PKCE flow, make the first authenticated request, and debug the common 401/403/429 failure modes. The meta-skill. If your agent has never spoken to Closient before, run this end-to-end and you'll have a working authenticated call against a real endpoint in under five minutes. ## When to use * An agent (MCP host, partner SDK, internal automation, third-party integration) is being onboarded against Closient for the first time. * A developer wants the canonical "hello world" path before reading any per-app API docs. * Token, scope, or rate-limit confusion needs to be reset to a known-good baseline. Once the quickstart works, jump to `closient-openapi-tour` for a breadth-first map of the 22 per-app APIs and individual skills like `local-product-search` for depth on a specific workflow. ## Environments | Environment | Site URL | Use for | | - | - | - | | Production | `https://www.closient.com` | Real traffic, real customers. | | Testing | `https://www.closient.dev` | Integration testing — same code, isolated data. | | Local | `http://localhost:8000` | Local dev only. | All endpoints in this doc are path-relative; prefix them with the env URL of your choice. Examples use `https://www.closient.com`. ## Step 1 — Discover what's available Closient publishes machine-readable discovery surfaces under `/.well-known/`. Hit them first to enumerate APIs and capabilities without grepping documentation: ``` GET /.well-known/api-catalog Accept: application/linkset+json ``` The response is an RFC 9727 linkset — one entry per per-app NinjaAPI, each with a `service-desc` pointing at that app's OpenAPI document: ```json theme={null} { "linkset": [ { "anchor": "https://www.closient.com/search/api/v1/", "service-desc": [{ "href": "https://www.closient.com/search/api/v1/openapi.json", "type": "application/openapi+json" }] } ] } ``` Sibling discovery endpoints worth knowing: | Endpoint | What it gives you | | - | - | | `/.well-known/agent-skills/index.json` | Index of every Agent Skill (this is one). | | `/.well-known/mcp.json` | MCP server card — connect via the MCP host of your choice. | | `/.well-known/openid-configuration` | OIDC / OAuth 2.0 discovery (token + auth endpoints, JWKS URI, supported scopes, RFC 7591 `registration_endpoint`). | | `/.well-known/oauth-authorization-server` | Plain OAuth 2.0 AS metadata (RFC 8414). | ## Step 2 — Pick a credential type Closient accepts **two** authentication shapes on every per-app API: | Mechanism | When to use | | - | - | | `X-API-Key` header (`csb__`) | Server-to-server jobs, ETL, internal automation. One key per service. Tier rate limits apply (default 300/min, 10k/day). | | OAuth 2.0 access token (`Authorization: Bearer ...`) | Agents acting on behalf of a Closient user. Supports per-scope authorization, refresh, revocation, and RFC 7591 self-registration. | Browser-based clients can also use the Django session cookie, but that isn't a useful agent path. **Quick rule:** if your agent's caller is *a person who has a Closient account*, use OAuth. If your caller is *another machine you own*, use an API key. Don't share OAuth client credentials between operators. ### 2a — Get an API key (fastest path) Sign in to Closient, open **Settings → API Keys**, and create a new key. The key is shown once; copy it then. Format: `csb__`. Verify by hitting the catalog search endpoint with no scope required: ``` GET /search/api/v1/search?q=trail+mix X-API-Key: csb__ ``` If you get `200 OK` with a JSON body containing `items` and `total`, the key is alive and you can skip to Step 4. (Catalog search is one of the few unauthenticated endpoints, so this also works without a key — a 401 here means the key string is malformed.) ### 2b — Register an OAuth client (RFC 7591 Dynamic Client Registration) For agents that need to act on behalf of *users*, register a client dynamically — no human admin required: ``` POST /oauth/register/ Content-Type: application/json { "client_name": "Acme Shopping Agent", "redirect_uris": ["https://agent.example.com/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "scope": "search:read products:read resolver:read", "token_endpoint_auth_method": "client_secret_basic", "software_id": "com.example.acme-shopping-agent", "software_version": "1.2.3" } ``` Response (RFC 7591 §3.2.1): ```json theme={null} { "client_id": "...", "client_secret": "...", "client_id_issued_at": 1734567890, "client_secret_expires_at": 0, "redirect_uris": ["https://agent.example.com/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "scope": "search:read products:read resolver:read", "token_endpoint_auth_method": "client_secret_basic" } ``` Notes: * The endpoint is **rate-limited per IP** (10 registrations/hour). Use a stable `software_id` so repeat runs deduplicate cleanly. * `client_secret_expires_at: 0` means the secret never expires (RFC 7591 §3.2.1) — but you can rotate at any time via the connected-apps dashboard. * The redirect URI must use HTTPS in production; `http://localhost` redirects are accepted in testing/local environments. * For a confidential server-to-server agent, use `client_credentials` in the `grant_types` array; you'll get tokens without a user in the loop. ## Step 3 — Run the authorization\_code + PKCE flow Public clients (mobile, SPA, CLI tools) MUST use PKCE — Closient's AS returns `invalid_request` if the code-verifier is missing. ### 3a — Mint the PKCE pair ```python theme={null} import base64 import hashlib import secrets code_verifier = secrets.token_urlsafe(64) code_challenge = base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest() ).rstrip(b"=").decode() ``` Hang onto `code_verifier` — you need it at the token exchange step. ### 3b — Redirect the user to the authorize endpoint ``` GET /oauth/authorize/? response_type=code& client_id=& redirect_uri=https://agent.example.com/oauth/callback& scope=search:read+products:read& state=& code_challenge=& code_challenge_method=S256 ``` The user sees a Closient-branded consent screen listing the agent name, logo, and the human-readable description of each requested scope. They click **Allow**; Closient redirects to your `redirect_uri` with `code` and `state` in the query string. If they click **Deny**, you'll receive `?error=access_denied` — surface it as "the user declined" rather than retrying. ### 3c — Exchange the code for tokens ``` POST /oauth/token/ Authorization: Basic base64(client_id:client_secret) Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=& redirect_uri=https://agent.example.com/oauth/callback& code_verifier= ``` Response: ```json theme={null} { "access_token": "...", "expires_in": 3600, "token_type": "Bearer", "refresh_token": "...", "scope": "search:read products:read", "id_token": "..." } ``` The `id_token` is a signed OIDC JWT — verify against the JWKS URI from `/.well-known/openid-configuration` before trusting the claims. ### 3d — Refresh when the access token expires ``` POST /oauth/token/ Authorization: Basic base64(client_id:client_secret) Content-Type: application/x-www-form-urlencoded grant_type=refresh_token& refresh_token= ``` ## Step 4 — Make your first authenticated call Pick any per-app endpoint. The search session endpoint is the canonical "hello world" for an agent because it returns local-first product results that exercise the geo-distance ranking pipeline: ``` POST /search/api/v1/search/session Authorization: Bearer Content-Type: application/json { "query": "organic peanut butter", "latitude": 48.4284, "longitude": -123.3656, "radius_km": 25 } ``` A successful response includes a session ID you can use to refine the query (subsequent POSTs to `/search/session/{id}/refine`) and an array of `items`, each annotated with `store`, `offer`, and `distance_km`. For something even simpler that doesn't require any auth at all: ``` GET /search/api/v1/search?q=trail+mix ``` If both of those work, you're done. The first row of data is the deliverable. ## Step 5 — Debug the common failures ### `401 Unauthorized` | Cause | What to check | | - | - | | Missing header | `X-API-Key` or `Authorization` header not sent — check your HTTP client's middleware. | | Malformed API key | Format is `csb__`. Three components, two underscores. | | Expired access token | Use the refresh token; OAuth access tokens are 1 h by default. | | Wrong environment | A testing-env API key won't work against production, and vice versa. | | Wrong client\_secret on token exchange | Re-derive `Authorization: Basic ...` — base64 of `client_id:client_secret`, no padding tricks. | ### `403 Forbidden` | Cause | What to check | | - | - | | Scope mismatch | OAuth token doesn't carry the scope the endpoint requires. Re-run authorize with `scope=` including the right per-app scope (e.g. `products:write`). | | Role mismatch | The user behind the token is a `VIEWER` on the org but the endpoint requires `MANAGER` or `OWNER`. | | Wrong organization | Cross-org access — the token's user isn't a member of the target organization. | | API key disabled | The key may have been revoked; create a new one. | ### `429 Too Many Requests` | Header | What it says | | - | - | | `RateLimit-Policy: 300;w=60, 10000;w=86400` | Two active windows: 300/minute and 10k/day. | | `RateLimit-Limit: 300` | Cap for the most-restrictive currently-active window. | | `RateLimit-Remaining: 12` | What's left in that window. | | `RateLimit-Reset: 7` | Seconds (relative) until that window resets. | These are standard IETF `RateLimit-*` headers (informed-governor). Use `RateLimit-Reset` to schedule the next retry — don't poll on 429 with no backoff. Legacy `X-RateLimit-*` aliases are also emitted; `X-RateLimit-Reset` keeps the absolute Unix-timestamp shape for back-compat. ### `400` with `error: invalid_grant` on token exchange * `code` was already redeemed (one-time-use). * `code_verifier` doesn't match the original `code_challenge`. * `redirect_uri` differs from what you sent at `/oauth/authorize/`. ### `400` with RFC 7591 `error: invalid_redirect_uri` on registration * HTTP redirect URI on production (HTTPS-only outside localhost). * Loopback URI uses a hostname instead of `127.0.0.1` / `[::1]`. ## Scopes you'll actually use Closient's OAuth scope set is per-app — every NinjaAPI gets a `:read` and a `:write`. The most common starter set: | Scope | What it grants | | - | - | | `search:read` | Catalog and session search. | | `products:read` | Product lookups, attributes, GTIN resolution. | | `resolver:read` | GS1 Digital Link resolution + linkset queries. | | `brands:read` / `brands:write` | Brand records (write needed for `claim-brand` flow). | | `retailers:read` / `retailers:write` | Offers + inventory (write needed for POS-driven onboarding). | | `account:read` | `whoami`, org membership, API-key listing. | | `qr:generate` | The QR-URL MCP tool (not REST-fronted). | The full canonical list is the `scopes_supported` array in `/.well-known/openid-configuration`. Always request the **narrowest scope set** that lets you complete the workflow — agents asking for write scope when they only read are flagged as suspicious in the consent UI. ## Related skills * `closient-openapi-tour` — bird's-eye map of the 22 per-app APIs. * `decode-gs1-ai` — once authenticated, parse GS1 AI strings into structured queries against `/products/api/v1/`. * `build-gs1-digital-link` — inverse of `decode-gs1-ai`. * `local-product-search`, `resolve-gtin`, `check-product-availability` — the most common workflows for an agent's *first* useful call. * `claim-brand`, `onboard-retailer` — onboarding-shaped workflows for authenticated org members. # Closient OpenAPI Tour Source: https://docs.closient.com/agent-skills/closient-openapi-tour Bird's-eye tour of the 22 per-app OpenAPI surfaces under Closient — what each app owns, where to find a specific endpoint via the RFC 9727 API catalog, and the cross-cutting conventions (auth, pagination, error envelope, rate-limit headers, idempotency) that apply uniformly. Closient is API-first and per-app. Each Django app owns its own `NinjaAPI` instance, mounted at `/{app}/api/v1/`, with its own OpenAPI 3.1 document at `/{app}/api/v1/openapi.json`. This skill is the map that gets an agent from "which app owns this concept?" to "this is the endpoint I want to hit" without grepping templates. ## When to use * An agent knows *what data* it needs but not which Closient app owns that data. * A developer is writing an SDK or MCP server and needs to enumerate every API surface. * The agent hit a `404 Not Found` and wants to confirm the endpoint path before assuming the API is broken. For zero-to-first-call setup (auth, scopes, PKCE), run `closient-api-quickstart` first. ## Discover everything from the API catalog The canonical enumeration is the RFC 9727 linkset at `/.well-known/api-catalog`: ``` GET /.well-known/api-catalog Accept: application/linkset+json ``` Each entry has: * `anchor` — the API's base URL (e.g. `https://www.closient.com/products/api/v1/`). * `service-desc[0].href` — the OpenAPI document URL. * `service-desc[0].type` — `application/openapi+json`. Programmatically: ```python theme={null} import httpx catalog = httpx.get("https://www.closient.com/.well-known/api-catalog").json() for entry in catalog["linkset"]: print(entry["anchor"], entry["service-desc"][0]["href"]) ``` The catalog is generated from `APP_API_REGISTRY` in `backend/apps/core/api/factory.py` — that's the source of truth if you're inside the monorepo. ## App-by-app — what each API owns | Mount | Label | Owns | | - | - | - | | `/account/api/v1/` | Account | Users, organizations, API keys, RBAC, OAuth-connected apps, whoami. Start here to identify the caller. | | `/agent/api/v1/` | Agent | LLM-powered conversational endpoints (chat, search refinement, tool-use orchestration). | | `/analytics/api/v1/` | Analytics | Scan events, search performance snapshots, brand engagement summaries. Read-heavy. | | `/billing/api/v1/` | Billing | Stripe subscription state, usage metrics, plan + tier endpoints. Read-only for non-owners. | | `/brands/api/v1/` | Brands | Brand records, ownership/claim flow, brand-level analytics. Write requires `brands:write`. | | `/byos/` | BYOS | "Bring your own search" — embedded search widget endpoints (not v1 prefixed by convention). | | `/certifications/api/v1/` | Certifications | Product certifications (organic, gluten-free, Fair Trade, …). | | `/compliance/api/v1/` | Compliance | Recalls, safety notices, FSMA 204 traceability (sibling to `/epcis/api/2.0/`). | | `/content/api/v1/` | Content | Wagtail-backed pages, blog posts, feeds, legal pages. | | `/dashboard/api/v1/` | Dashboard | Aggregated views consumed by the Closient web UI. Not a public-facing API. | | `/epcis/api/2.0/` | EPCIS 2.0 | GS1 EPCIS 2.0 event capture + query. Path mounted at `/api/2.0/` per spec, not `/v1/`. | | `/integrations/api/v1/` | Integrations | POS connections (Shopify, Square, Clover, Lightspeed), webhooks. | | `/locations/api/v1/` | Locations | Physical store/service locations with PostGIS geometry. | | `/notifications/api/v1/` | Notifications | Multi-channel notification delivery (email, Slack, SMS). | | `/products/api/v1/` | Products | Products (GTINs), images, validators, catalog API, categories, QR rendering. | | `/resolver/api/v1/` | Resolver | GS1 Digital Link resolution + linkset queries + serial verification. | | `/retailers/api/v1/` | Retailers | Offers (in-store + online), inventory sources, online-retailer integrations. | | `/scanner/api/v1/` | Scanner | Barcode + product scanning, vision-extracted attributes. | | `/search/api/v1/` | Search | Catalog search, session-based local-first search, refinement. | | `/sustainability/api/v1/` | Sustainability | Sustainability certifications + impact tracking. | The list is canonical at `backend/apps/core/api/factory.py:APP_API_REGISTRY`. When that list changes, the catalog updates automatically; you don't need to re-grep. Staff-only APIs are deliberately absent from this table and from `/.well-known/api-catalog`. `APP_API_REGISTRY` marks them `internal=True`; their specs are auth-gated and live under `docs/internal/openapi/`. If you are reading this as an external consumer, the public list above is the whole surface available to you. ## Find an endpoint across the 22 APIs Three strategies, fastest first: ### 1. Browse Mintlify docs Interactive HTML docs aggregate every per-app OpenAPI spec at `https://docs.closient.com`. Search the sidebar by resource name — "product", "brand", "offer", "recall" — and you'll get the canonical endpoint URL with auth, parameters, and response schemas. ### 2. Fetch the OpenAPI doc directly For machine consumption (SDK generation, MCP tool registration), grab the per-app spec straight from the catalog and parse it: ```python theme={null} spec = httpx.get( "https://www.closient.com/products/api/v1/openapi.json" ).json() for path, methods in spec["paths"].items(): for verb, op in methods.items(): print(verb.upper(), path, "—", op.get("summary")) ``` Every per-app spec follows the same shape (Django Ninja → OpenAPI 3.1). ### 3. Search the linkset The catalog itself is linkset-shaped, so you can grep through `anchor` and `service-desc.href` to find candidate apps before fetching their specs. Useful in an MCP / tool-use context where dropping every spec into a prompt is wasteful. ## Cross-cutting conventions (apply to every per-app API) ### Authentication Every API accepts the same three auth mechanisms; pick one: | Mechanism | Header / cookie | | - | - | | API key | `X-API-Key: csb__` | | OAuth 2.0 access token | `Authorization: Bearer ` | | Session (browser only) | Django `sessionid` cookie | Endpoints that need no auth (catalog search, public resolver, well-known endpoints) declare `auth=None` in the OpenAPI spec and accept anonymous calls subject to a tighter IP-based throttle. ### Pagination List endpoints use **limit/offset** pagination (cursor pagination is NOT in use): ``` GET /products/api/v1/products?limit=50&offset=100 ``` Response envelope: ```json theme={null} { "items": [...], "count": 50, "total": 1234, "limit": 50, "offset": 100 } ``` Defaults: `limit=25`, max `limit=100`. Implemented by the shared `apps.core.api.pagination.LimitOffsetPagination`. ### Error envelope (RFC 9457 Problem Details) All errors return `application/problem+json`: ```json theme={null} { "type": "https://www.closient.com/problems/forbidden", "title": "Forbidden", "status": 403, "detail": "User does not have brands:write scope on this token.", "instance": "/brands/api/v1/brands/abc123" } ``` `type` is a stable URI (you can build clients that switch on it); `title` is human-readable; `detail` may include user-context information. Wired by `apps.core.api.exceptions.register_error_handlers`. ### Rate-limit headers Every response carries IETF `RateLimit-*` headers: | Header | Value | | - | - | | `RateLimit-Policy` | All active windows, e.g. `300;w=60, 10000;w=86400`. | | `RateLimit-Limit` | Quota for the most-restrictive currently-active window. | | `RateLimit-Remaining` | Requests left in that window. | | `RateLimit-Reset` | Seconds until that window resets (relative; clock-skew safe). | Legacy `X-RateLimit-*` aliases are also emitted; `X-RateLimit-Reset` keeps the absolute Unix-timestamp shape for back-compat. Implemented in `apps.core.api.throttling`. Default: 300/min, 10k/day per key. ### Idempotency Mutating endpoints accept `Idempotency-Key` headers where the operation is naturally idempotent (POSTs that look like upserts, batch ingest endpoints). The key is opaque to Closient; same key + same body returns the original response. Check the per-endpoint OpenAPI description for the explicit `Idempotency-Key` parameter declaration before relying on it — not every POST is idempotent. ### Empathy contract Every `Field()` in every per-app schema carries a `description=` (or auto-pipes from the model's `help_text` via `ModelDocumentedSchema`), and enum-shaped names are typed as `StrEnum`s with the values declared on the schema. SDK generators get a real `enum` constraint instead of a bare string. See `backend/apps/core/CLAUDE.md` "API Empathy" for the internal contract. ### OpenAPI specs are versioned per app When an app evolves a breaking change, expect a `/api/v2/` mount alongside `/api/v1/`. Today only EPCIS 2.0 lives outside the `v1` convention (it sits at `/api/2.0/` to match the GS1 EPCIS spec naming). There is no global `/v1/`. ## Cross-app patterns to recognise ### "List then detail" For most resources, list endpoints return a slim representation; detail endpoints return the full one: ``` GET /products/api/v1/products → ProductListItem (slim) GET /products/api/v1/products/{id} → ProductDetail (full) ``` Don't filter the list response client-side hoping to find a field — fetch the detail endpoint or use the documented query parameters. ### Short IDs in URLs Public-facing resources use a 22-character `ShortUUIDField` for both PKs and URL paths (see `apps.core.fields.ShortUUIDField`): ``` GET /brands/api/v1/brands/4Vh87cAnSyrSt5KQ8s9hkF ``` Short IDs are accepted in both shortuuid and full-UUID form; the URL converter normalizes. New code emits the short form. ### Scoped to organization Resources owned by an organization (Products, Brands, Offers, Locations, EPCIS events, …) are filtered by the caller's organization membership. Cross-org reads return `404 Not Found` rather than `403`, to avoid leaking existence. ### Webhooks vs polling Closient prefers webhooks over polling for state changes. Subscribe via `/notifications/api/v1/webhooks` (signed with HMAC-SHA256). Endpoints that emit events are documented in each per-app OpenAPI spec; common ones: scanner events, recall notices, offer changes, EPCIS captures. ## Putting it all together A typical agent boot sequence: 1. Hit `/.well-known/api-catalog` to learn what's available. 2. Hit `/.well-known/openid-configuration` to learn how to auth. 3. Run `closient-api-quickstart` to register / authenticate. 4. `GET /account/api/v1/whoami` to confirm the token's identity. 5. Walk per-app OpenAPI specs from the catalog to bind tools. Once tools are bound, the per-workflow skills (`local-product-search`, `resolve-gtin`, `claim-brand`, `capture-epcis-event`, …) tell the agent *what to do* with them. ## Related skills * `closient-api-quickstart` — auth, scopes, first call. * `decode-gs1-ai`, `build-gs1-digital-link` — GS1 identifier mechanics. * `local-product-search`, `resolve-gtin`, `check-product-availability` — search + product depth. * `claim-brand`, `onboard-retailer` — onboarding workflows. * `capture-epcis-event`, `query-epcis-events`, `manage-recall`, `check-fsma-204-readiness` — supply-chain and compliance. * `brand-scan-summary`, `brand-search-demand`, `brand-retail-footprint` — brand analytics. ## Authoritative references * RFC 9727 — API Catalog / Linkset. `https://datatracker.ietf.org/doc/html/rfc9727` * RFC 9457 — Problem Details for HTTP APIs. * IETF `RateLimit-*` headers (informed governor draft). * OpenAPI 3.1 spec — generated docs at `https://docs.closient.com`. * Closient internal: `backend/apps/core/api/factory.py`, `backend/apps/core/api/pagination.py`, `backend/apps/core/api/throttling.py`, `backend/apps/core/api/exceptions.py`. # Compare Products Source: https://docs.closient.com/agent-skills/compare-products Side-by-side comparison of two or more products on price, certifications, ingredients, and provenance. Given 2+ GTINs, produce a structured diff covering what matters for a purchase decision: price, certifications, ingredients/substances, net content, country of origin, brand ownership. ## When to use * User says "which of these is better?" or "what's the difference?" * Following `find-alternative` to justify why the alternative wins. * Before `plan-shopping-trip` to pick between two viable candidates. ## Flow Closient does not yet have a single comparison endpoint. The pattern is: ### 1. Fetch each product ``` GET /products/api/v1/products/{gtin_a} GET /products/api/v1/products/{gtin_b} ``` ### 2. Fetch side data per GTIN * `GET /certifications/api/v1/products/{gtin}` — certifications held * `GET /compliance/api/v1/products/{gtin}/recalls?status=ONGOING` — any active recalls (dealbreaker!) * If location is known, optionally `check-product-availability` for stock * price per store ### 3. Structure the diff Group by category so the user can scan: ``` Price: A=$4.99 B=$5.49 Net content: A=946 mL B=1 L Certifications: A=[organic, non-gmo] B=[organic] Country: A=USA B=Canada Data confidence: A=0.93 B=0.88 Active recalls: A=none B=none ``` ### 4. Highlight meaningful differences * Certifications one has that the other lacks * Price per unit (normalize by net\_content) * Brand ownership / claim status * Any recall on either → surface as critical ## Guidance for agents * **Normalize units**: report price per standard unit (per 100 mL, per 100 g) rather than raw price; lets the user compare apples to apples. * **Cite data quality**: if `confidence_score < 0.7` on a critical field, note that the data is inferred. * **Recalls are a hard flag**: any CLASS\_I recall means that option is out. ## Planned A single `POST /products/api/v1/compare` endpoint accepting N GTINs and returning a structured diff with pre-normalized fields. ## Related skills * `resolve-gtin` — for each product being compared * `check-recalls` — critical safety check per candidate * `find-alternative` — often ends by calling this to justify the pick # Create Product Source: https://docs.closient.com/agent-skills/create-product Create a new product (GTIN) and claim it for a brand-owner organization — the programmatic equivalent of the dashboard "Add product" page. Programmatic equivalent of the dashboard's `products/new/` page. Use this instead of driving that form when you already have the product's GTIN and core details. ## When to use * A brand owner (or an agent acting on their behalf) needs to add a new GTIN to Closient and claim it for their organization. * The GTIN does not already exist on Closient. If it does, use `POST /products/api/v1/products/{gtin}/claim` instead — this endpoint returns 409 for an existing GTIN. ## Auth `X-API-Key` header or OAuth token with `products:write` scope. The caller must be a member of the organization named by `organization_id`. ## Flow ``` POST /products/api/v1/products ``` Body (JSON): | Field | Required | Notes | | - | - | - | | `gtin` | yes | GTIN-8/12/13/14; normalized/validated server-side. | | `organization_id` | yes | Organization claiming the product. | | `product_name` | no | Display name as marketed on packaging. | | `description` | no | Long-form description. | | `country_of_origin` | no | ISO 3166-1 alpha-2. | | `net_content` | no | Free-text net content (e.g. `"340g"`). | | `ingredients` | no | Raw ingredient list text; triggers substance parsing. | | `metadata` | no | Arbitrary key/value map (Stripe-style semantics). | Returns `201` with the created `ProductOut` on success, `403` if the caller lacks a role on `organization_id`, `409` if the GTIN is already claimed, `422` for a malformed GTIN or a banned consumer date-label phrase in a free-text field (AB 660). ## Notes * Every other field the dashboard exposes (dimensions, GPC classification, images, nutrition, documents, videos) is added afterward via the sibling skills — `edit-product-basic-info`, `edit-product-dimensions`, `edit-product-classification`, `edit-product-images`, `edit-product-ingredients`, `edit-product-nutrition`, `edit-product-documents`, `edit-product-videos`. # Decode GS1 AI Source: https://docs.closient.com/agent-skills/decode-gs1-ai Parse a GS1 Application Identifier (AI) element string — bracketed or unbracketed — into structured key/value pairs, distinguishing fixed-length AIs (GTIN, dates) from variable-length AIs that require an FNC1 separator. GS1 barcodes carry data as a concatenation of *Application Identifier* (AI) + value pairs. AIs are numeric prefixes (2-4 digits) that announce what the next value means: `01` = GTIN, `10` = batch/lot, `21` = serial, `00` = SSCC, `8003` = GRAI, and so on. This skill teaches an agent how to take an element string off a barcode scan and unpack it into structured data the rest of the system can query. ## When to use * A scanner / OCR / EDI feed produced a raw element string and you need structured fields (GTIN, lot, expiry, serial, SSCC, GRAI, GIAI). * The agent received a GS1 Digital Link (`https://id.gs1.org/01/...`) and needs to extract the AIs the path encodes. * A user pasted a GS1-128 barcode payload and asked "what does this mean?". For the inverse — building an AI element string or Digital Link URL *from* structured data — see `build-gs1-digital-link`. ## The two encodings | Encoding | Example | When you see it | | - | - | - | | **Bracketed (human readable)** | `(01)00614141000005(10)A123(21)SN9999` | EDI documents, marketing copy, debug output. | | **Unbracketed (machine readable)** | `010061414100000510A123\x1d21SN9999` | Actual barcode payload (GS1-128, DataMatrix, QR with GS1 framing). | Both encode the same data; bracketed form just makes the AI boundaries visible. The unbracketed form uses **FNC1** (`\x1d`, also written `` or ASCII 29) to mark the end of a variable-length value. ## Step 1 — Detect the variant ```python theme={null} def detect_variant(s: str) -> str: if s.startswith("("): return "bracketed" if s.startswith("https://") or s.startswith("http://"): return "digital_link" return "unbracketed" ``` Three real shapes to expect: 1. `(01)09506000134352(17)270101(10)ABC123` → **bracketed** 2. `010950600013435217270101\x1d10ABC123` → **unbracketed** 3. `https://id.gs1.org/01/09506000134352/10/ABC123` → **Digital Link path** ## Step 2 — Know the fixed-length AIs These AIs have a **predefined length**; the parser jumps that many characters and continues. No separator needed. | AI | Length (after AI) | Field | Notes | | - | - | - | - | | `00` | 18 | SSCC | Serial Shipping Container Code. | | `01` | 14 | GTIN | Always zero-padded to GTIN-14 internally. | | `02` | 14 | GTIN of contained trade items | For logistics labels. | | `11` | 6 | Production date (YYMMDD) | | | `12` | 6 | Due date (YYMMDD) | | | `13` | 6 | Packaging date (YYMMDD) | | | `15` | 6 | Best-before date (YYMMDD) | | | `16` | 6 | Sell-by date (YYMMDD) | | | `17` | 6 | Expiration date (YYMMDD) | Day `00` means "end of month". | | `20` | 2 | Variant number | | | `8005` | 6 | Price per unit of measure | | | `8006` | 18 | GCTIN (component) | | ## Step 3 — Know the variable-length AIs These accept up to a maximum number of characters and **must** be terminated by FNC1 (`\x1d`) when followed by another AI. If the variable-length value is the *last* element in the string, no separator is needed. | AI | Max length | Field | | - | - | - | | `10` | 20 | Batch / lot number | | `21` | 20 | Serial number | | `22` | 20 | Consumer product variant | | `240` | 30 | Additional product identification | | `241` | 30 | Customer part number | | `242` | 6 | Made-to-order variation | | `30` | 8 | Variable count | | `37` | 8 | Count of trade items | | `400` | 30 | Customer purchase order | | `401` | 30 | Consignment number (GINC) | | `402` | 17 | Shipment identification (GSIN) | | `403` | 30 | Routing code | | `410`–`417` | 13 | Trading-partner location codes | | `420` | 20 | Ship-to postal code (single postal authority) | | `421` | 12 | Ship-to postal code + ISO country code | | `8003` | up to 30 | GRAI (Global Returnable Asset Identifier) | | `8004` | up to 30 | GIAI (Global Individual Asset Identifier) | | `8011` | 12 | Component / part serial | | `8020` | 25 | Payment slip reference | | `90`–`99` | 30 | Internal applications | ## Step 4 — Parse, bracketed → structured ```python theme={null} import re BRACKETED_RE = re.compile(r"\((\d{2,4})\)([^(]*)") def parse_bracketed(s: str) -> dict[str, str]: return dict(BRACKETED_RE.findall(s)) parse_bracketed("(01)09506000134352(17)270101(10)ABC123") # {'01': '09506000134352', '17': '270101', '10': 'ABC123'} ``` Trivial — the brackets do the framing work for you. ## Step 5 — Parse, unbracketed → structured This is where most agents trip up. The algorithm: 1. Read 2-4 leading digits and try them against the AI table. Try the longest prefix that matches a known AI (so `8003` is preferred over `80` + `03`). 2. If the AI is fixed-length, slice `length` characters as the value. 3. If it's variable-length, slice up to the next `\x1d` (or end of string, if there's no separator). 4. Repeat from the position after the value. ```python theme={null} FIXED = {"00":18, "01":14, "02":14, "11":6, "12":6, "13":6, "15":6, "16":6, "17":6, "20":2, "8005":6, "8006":18} VAR_MAX = {"10":20, "21":20, "22":20, "240":30, "241":30, "242":6, "8003":30, "8004":30} FNC1 = "\x1d" def parse_unbracketed(s: str) -> dict[str, str]: out, i = {}, 0 while i < len(s): # try longest AI prefix first (4 → 2 chars) for n in (4, 3, 2): ai = s[i:i+n] if ai in FIXED or ai in VAR_MAX: i += n break else: raise ValueError(f"Unknown AI at offset {i}: {s[i:i+4]!r}") if ai in FIXED: length = FIXED[ai] out[ai] = s[i:i+length] i += length else: end = s.find(FNC1, i) end = len(s) if end == -1 else end out[ai] = s[i:end] i = end + (1 if end < len(s) else 0) return out ``` Production parsers should also: * Normalize bracketed input by stripping brackets and re-emitting an unbracketed string with FNC1 separators inserted, then run the unbracketed algorithm. One code path is easier to maintain than two. * Validate the Mod-10 check digit on AI `01` (GTIN). The Closient `apps.core.types.GTIN.from_raw()` does this — reuse if you're in Python. * Accept the literal characters `^` or `~` as FNC1 sentinels (some scanners emit those instead of `\x1d`). ## Step 6 — Parse, GS1 Digital Link → structured Digital Link path is path-based AI encoding: `/{AI}/{value}/{AI}/{value}/…` ```python theme={null} import re from urllib.parse import urlparse PATH_SEG_RE = re.compile(r"/(\d{2,4})/([^/]+)") def parse_digital_link(url: str) -> dict[str, str]: parts = PATH_SEG_RE.findall(urlparse(url).path) return dict(parts) parse_digital_link("https://id.gs1.org/01/09506000134352/10/ABC123/21/SN9999") # {'01': '09506000134352', '10': 'ABC123', '21': 'SN9999'} ``` Closient's own resolver (`apps.products.gs1_digital_link.parse_gs1_digital_link`) returns a typed dataclass with `gtin`, `lot_number`, `expiry_date`, and `serial_number` already broken out — call that directly if you're running inside the monorepo. ## Step 7 — Validate Beyond the AI table, two checks matter: * **GTIN check digit (AI 01).** Mod-10; the last digit is computed from the first 13. Reject silently-malformed scans rather than continuing with a corrupt GTIN. * **Date plausibility (AI 17 in particular).** `YYMMDD` with `MM` in 01-12, `DD` in 00-31 (where `00` is sentinel for "last day of month"). The Closient scanner emits structured `freshness_meta` for AI 17 — see `apps.scanner.services.gs1_dates`. ## Example end-to-end Input (off a meat-case label): ``` 0109506000134352173009011045454GH\x1d21SN0001 ``` Steps: 1. `01` → fixed 14 → `09506000134352` (GTIN). 2. `17` → fixed 6 → `300901` (expiry `2030-09-01`). 3. `10` → variable, up to FNC1 → `45454GH` (lot). 4. `21` → variable, no separator left → `SN0001` (serial). Structured: ```json theme={null} { "01": "09506000134352", "17": "300901", "10": "45454GH", "21": "SN0001" } ``` After that, Closient endpoints to call next: ``` GET /products/api/v1/products?gtin=09506000134352 GET /resolver/api/v1/verify/01/09506000134352/21/SN0001 ``` ## Common mistakes * **Forgetting FNC1 after a variable-length AI** before another AI starts — produces a corrupted parse where `10ABC12321SN0001` is read as a 20-character lot ending in `21SN0001`. * **Not zero-padding GTIN-8 or GTIN-12 to GTIN-14** before lookups — Closient stores GTINs as GTIN-14 internally; pad with leading zeros. * **Treating `(01)` from a Digital Link query string as the SSCC AI 00** because the parser was eager about 2-digit prefixes; always try the longest AI prefix first. * **Trusting `YY` as 4-digit year.** GS1 says `YY` in 00-49 → 20YY, 50-99 → 19YY (sliding window). Don't hardcode the century. ## Related skills * `build-gs1-digital-link` — inverse: given GTIN + AIs, emit a valid Digital Link URI. * `resolve-gtin` — fetch product detail once you have the GTIN. * `check-product-availability` — find local stock for a GTIN. * `capture-epcis-event` — once you have GTIN + lot + serial, record a supply-chain event. * `closient-api-quickstart` — authenticate before calling any of the above. ## Authoritative references * GS1 General Specifications (v25 or later) — section 3 (AIs) and section 7 (data carriers). * GS1 Digital Link Standard 1.4. * ISO/IEC 15418 — GS1 Application Identifiers. * Closient internal: `backend/apps/products/gs1_digital_link.py` (parser) and `backend/apps/core/types/_gs1.py` (typed value objects). # Dietary Filter Search Source: https://docs.closient.com/agent-skills/dietary-filter-search Search for products constrained by stored dietary, allergen, religious, or sourcing preferences. Search that respects the user's persistent trait preferences — allergens they must avoid, dietary stances (vegan, kosher, halal), religious constraints, and sourcing preferences (organic, fair-trade, local). ## When to use * User has a preference profile (explicitly set or inferred) and searches without restating "and no nuts please" each time. * Any recipe / meal / grocery workflow where preferences should be defaulted. ## Preferences model Closient tracks a user's `TraitPreference` list — each entry is: * `trait` — lower-snake-case slug, e.g. `peanuts` (allergen), `vegan` (dietary), `kosher` (religious). Pass directly into search constraints. * `stance` — `AVOID` / `REQUIRE` / `PREFER` / `NEUTRAL` * `category` — ALLERGEN / DIETARY / RELIGIOUS / SOURCING ## Flow (end-to-end) ### 1. Read user preferences ``` GET /account/api/v1/preferences Authorization: Bearer ``` Returns the caller's full preference list as JSON: ```jsonc theme={null} [ {"trait": "peanuts", "trait_label": "Peanuts", "category": "ALLERGEN", "stance": "AVOID"}, {"trait": "vegan", "trait_label": "Vegan", "category": "DIETARY", "stance": "REQUIRE"}, {"trait": "organic", "trait_label": "Organic", "category": "SOURCING", "stance": "PREFER"} ] ``` Fresh accounts return `200` + `[]`. Read-only — writes still happen through the dashboard's HTMX form at `/preferences/`. ### 2. Translate preferences into constraints | Category | Stance | Goes into | | - | - | - | | ALLERGEN | AVOID | `exclude_allergens` | | DIETARY | REQUIRE | `require_certifications` | | RELIGIOUS | REQUIRE | `require_certifications` | | SOURCING | REQUIRE | `require_certifications` | | ANY | PREFER | rank boost (client-side) | | ANY | NEUTRAL | ignored | ### 3. Run a constrained search The unified surface accepts the three constraint sets directly — either as list query params on `GET /search/api/v1/search` or as `constraints.*` keys on `POST /search/api/v1/search/session`: ``` GET /search/api/v1/search?q=oat+milk &exclude_allergens=peanuts &require_certifications=vegan &require_certifications=organic ``` ``` POST /search/api/v1/search/session { "query": "oat milk", "latitude": 30.267, "longitude": -97.743, "constraints": { "exclude_allergens": ["peanuts"], "require_certifications": ["vegan", "organic"], "avoid_substances": ["sucralose"] } } ``` The session route also picks up the same slugs from natural-language phrasing — `"avoid peanuts"`, `"vegan certified"`, `"no sucralose"` — so unauthenticated callers without a stored preference list still get the filter for free. ### 4. Read the trait fields on each result `SearchResultOut` carries the fields inline so no per-candidate round-trip is needed: * `certifications: string[]` — certification slugs the product holds (unions `CertificationAssignment` codes with non-allergen `Trait` links — DIETARY / RELIGIOUS / SOURCING). * `contains_allergens: string[]` — allergen slugs detected on the product (unions allergen-typed substance composition with explicit ALLERGEN `Trait` links). * `substance_flags: string[]` — substances that matched the current request's `avoid_substances` filter, so the UI can show "flagged because X". * `allergen_data_confidence: "high" | "medium" | "low"` — * `high`: explicit substance composition matched (we *know* what's in it). * `medium`: explicit allergen `Trait` link only (asserted, not compositional). * `low`: neither signal — treat missing data as *not* clean. ## Guidance for agents * **AVOID always beats PREFER** — never recommend something that fails an AVOID constraint. * **Trust `high`/`medium` confidence; question `low`.** When the user has a hard avoid constraint and the row's confidence is `low`, drop the candidate or ask the user instead of recommending blindly. * **Surface the rationale** — "Showing Product X because it's certified vegan and avoids peanuts per your preferences." * **Ask about gaps**, don't silently guess. If the user hasn't set a preference relevant to a tricky product (e.g. glycerin for strict vegans), ask before deciding for them. ## Related skills * `local-product-search` — the base search this skill wraps * `find-alternative` — when a result fails a constraint, use this to substitute # Edit Product Basic Info Source: https://docs.closient.com/agent-skills/edit-product-basic-info Update a claimed product's identity fields (name, brand, description, manufacturer, origin, etc.) — the programmatic equivalent of the dashboard product-edit page. Programmatic equivalent of the dashboard's `products//edit/` page. ## When to use * A brand owner needs to update the name, brand, description, manufacturer, country of origin, net content, usage instructions, canonical product-page URL, primary language, or category of a product they've already claimed. ## Auth `X-API-Key` header or OAuth token with `products:write` scope. The caller must be an `OWNER`, `MANAGER`, or `EDITOR` on the organization that owns the GTIN. ## Flow ``` PATCH /products/api/v1/products/{gtin} ``` Every field is optional — only fields explicitly present in the request body are written; omit a field (or send it as `null`) to leave it unchanged. | Field | Notes | | - | - | | `product_name` | Display name as marketed on packaging. | | `brand` | Looked up by name under the caller's org; created if no match. | | `description` | Long-form description. | | `manufacturer` | Looked up by name; created if no match. | | `country_of_origin` | ISO 3166-1 alpha-2. | | `net_content` | Free-text (e.g. `"340g"`). | | `usage_instructions` | Directions for use. | | `brand_product_page_url` | Absolute URL of the brand's canonical page. | | `primary_language` | ISO 639-1. | | `category_id` | `ProductCategory` UUID. | | `metadata` | Stripe-style semantics — empty string deletes a key, `{}` clears all. | Returns `200` with the updated `ProductOut`, `403` if the caller lacks the required role, `404` if the GTIN isn't owned by the caller's organization, `422` for a banned consumer date-label phrase (AB 660) in a free-text field. ## Related skills Dimensions/weight, GPC classification, and ingredients are separate fields on the same `PATCH` endpoint — see `edit-product-dimensions`, `edit-product-classification`, and `edit-product-ingredients`. Images, nutrition, documents, and videos are separate resource families on the Dashboard API — see their respective skills. # Edit Product Classification (GPC) Source: https://docs.closient.com/agent-skills/edit-product-classification Update a product's GS1 Global Product Classification (GPC) codes — the programmatic equivalent of the dashboard's classification page. Programmatic equivalent of the dashboard's `products//classification/` page. ## When to use * A brand owner needs to set or correct a product's GS1 GPC segment, family, class, or brick code. ## Auth `X-API-Key` header or OAuth token with `products:write` scope. The caller must be an `OWNER`, `MANAGER`, or `EDITOR` on the organization that owns the GTIN. ## Flow ``` PATCH /products/api/v1/products/{gtin} ``` Every field is optional; omit or send `null` to leave unchanged. | Field | Notes | | - | - | | `gpc_segment_code` | Top-level GPC classification. | | `gpc_family_code` | Parent of class. | | `gpc_class_code` | Parent of brick. | | `gpc_brick_code` | 8-digit GS1 GPC brick code — the most specific level. | Returns `200` with the updated `ProductOut`, `403` for a caller without the required role, `404` if the GTIN isn't owned by the caller's organization. ## Related skills This is the same `PATCH` endpoint as `edit-product-basic-info` and `edit-product-dimensions` — the dashboard splits it across three pages, but a single request can set fields from all three at once. # Edit Product Dimensions & Weight Source: https://docs.closient.com/agent-skills/edit-product-dimensions Update a product's physical dimensions and weight (length/width/height/depth, gross/net weight) — the programmatic equivalent of the dashboard's dimensions & weight page. Programmatic equivalent of the dashboard's `products//dimensions/` page. ## When to use * A brand owner needs to enter or correct GDSN/GS1 physical-measurement fields for a claimed product: length, width, height, depth, gross weight, or net weight. ## Auth `X-API-Key` header or OAuth token with `products:write` scope. The caller must be an `OWNER`, `MANAGER`, or `EDITOR` on the organization that owns the GTIN. ## Flow ``` PATCH /products/api/v1/products/{gtin} ``` Every field is optional; omit or send `null` to leave unchanged. Each numeric value pairs with its own UN/CEFACT unit-of-measure code. | Field | Unit field | Notes | | - | - | - | | `length` | `length_uom` | e.g. `CMT`, `MMT`, `MTR`, `INH` | | `width` | `width_uom` | same unit set | | `height` | `height_uom` | same unit set | | `depth` | `depth_uom` | same unit set | | `gross_weight` | `gross_weight_uom` | including packaging — e.g. `GRM`, `KGM`, `LBR`, `ONZ` | | `net_weight` | `net_weight_uom` | excluding packaging — same unit set | All numeric values must be `>= 0` (a negative dimension returns `422`). Returns `200` with the updated `ProductOut`, `403` for a caller without the required role, `404` if the GTIN isn't owned by the caller's organization. ## Related skills This is the same `PATCH` endpoint as `edit-product-basic-info` and `edit-product-classification` — the dashboard splits it across three pages, but a single request can set fields from all three at once. # Edit Product Documents Source: https://docs.closient.com/agent-skills/edit-product-documents Upload, update, list, and delete a product's PDF documents — the programmatic equivalent of the dashboard's documents page. Programmatic equivalent of the dashboard's `products//documents/` page (and its `documents/new/`, `documents//edit/`, `documents//delete/` sub-pages). ## When to use * A brand owner needs to attach a spec sheet, safety data sheet, or other PDF document to a product, or update/remove one already attached. ## Auth `X-API-Key` header or OAuth token with `dashboard:write` scope. The caller must be a member of `organization_id` and hold the `organization.contribute_organization` permission; the product must be owned by that organization. ## Flow ``` GET /dashboard/api/v1/products/{organization_id}/{gtin}/documents POST /dashboard/api/v1/products/{organization_id}/{gtin}/documents PATCH /dashboard/api/v1/products/{organization_id}/{gtin}/documents/{document_id} DELETE /dashboard/api/v1/products/{organization_id}/{gtin}/documents/{document_id} ``` * **List** — paginated, most-recent first. * **Upload** (`multipart/form-data`) — a single PDF per request; non-PDF uploads are rejected with `422`. Page count and thumbnail are extracted asynchronously after upload, so those fields may be `null` in the immediate response. * **Update** — metadata only (e.g. title/label), not the file itself; upload a new document and delete the old one to replace the file. * **Delete** — removes a single document row; `document_id` must belong to the targeted product. Returns `201` on upload, `403` for a caller without the contribute permission, `404` for a GTIN/document not owned by `organization_id`, `422` for a non-PDF upload. ## Notes Writes go through an audited `@org_action` service, including the post-upload PDF-processing dispatch — the direct `form.save()` this dashboard page used before C-4070 is gone. # Edit Product Images Source: https://docs.closient.com/agent-skills/edit-product-images Upload, list, and delete a product's images — the programmatic equivalent of the dashboard's images page. Programmatic equivalent of the dashboard's `products//images/` page (and its `images/new/`, `images//edit/`, `images//delete/` sub-pages). ## When to use * A brand owner needs to add, review, or remove images attached to a product. ## Auth `X-API-Key` header or OAuth token with `dashboard:write` scope. The caller must be a member of `organization_id` and hold the `organization.contribute_organization` permission on their membership; the product must be owned by that organization. ## Flow ``` GET /dashboard/api/v1/products/{organization_id}/{gtin}/images POST /dashboard/api/v1/products/{organization_id}/{gtin}/images DELETE /dashboard/api/v1/products/{organization_id}/{gtin}/images/{image_id} ``` * **List** — paginated, primary image first, then ascending `priority`, then most-recent upload. * **Upload** (`multipart/form-data`) — accepts JPEG, PNG, GIF, WebP, SVG, or BMP. When the uploaded image is marked `is_primary`, the previously primary image (if any) is automatically demoted, so at most one image per product carries the flag. * **Delete** — removes a single image row; `image_id` must belong to the targeted product. Returns `201` on upload with the created image row, `403` for a caller missing the contribute permission, `404` for a GTIN/image not owned by `organization_id`, `422` for an unsupported file type. ## Notes The Dashboard API (`/dashboard/api/v1/...`) is internal-only tooling powering this dashboard, not a general-purpose external integration surface — but it is the real, documented write path for this workflow and safe to call with a scoped `dashboard:write` token. # Edit Product Ingredients Source: https://docs.closient.com/agent-skills/edit-product-ingredients Update a product's raw ingredient list text and trigger substance parsing — the programmatic equivalent of the dashboard's ingredients page. Programmatic equivalent of the dashboard's `products//ingredients/` page. ## When to use * A brand owner needs to set or replace the verbatim ingredient-list text printed on a product's packaging. ## Auth `X-API-Key` header or OAuth token with `products:write` scope. The caller must be an `OWNER`, `MANAGER`, or `EDITOR` on the organization that owns the GTIN. ## Flow ``` PATCH /products/api/v1/products/{gtin} ``` | Field | Notes | | - | - | | `ingredients` | Raw ingredient-list text, verbatim from the package. When provided, the parser splits the text into spans and matches each span to a `Substance` record. Omit to skip ingredient parsing. | Returns `200` with the updated `ProductOut`, `403` for a caller without the required role, `404` if the GTIN isn't owned by the caller's organization. ## Related skills This is the same `PATCH` endpoint used by `edit-product-basic-info`, `edit-product-dimensions`, and `edit-product-classification` — a single request can combine fields from all four. # Edit Product Nutrition Source: https://docs.closient.com/agent-skills/edit-product-nutrition Create, update, and delete a product's nutrition-facts panel and nutrient rows — the programmatic equivalent of the dashboard's nutrition page. Programmatic equivalent of the dashboard's `products//nutrition/` page (and its per-nutrient `nutrition//edit/`, `nutrition//delete/` sub-pages). ## When to use * A brand owner needs to record or correct a product's nutrition-facts header (serving size, servings per container, label format) or its individual nutrient rows (calories, fat, sodium, etc.). ## Auth `X-API-Key` header or OAuth token with `dashboard:write` scope. The caller must be a member of `organization_id` and hold the `organization.contribute_organization` permission; the product must be owned by that organization. ## Flow The header (one-per-product) and nutrient rows are managed separately. ``` GET /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition PUT /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition POST /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition/nutrients PATCH /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition/nutrients/{nutrient_id} DELETE /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition/nutrients/{nutrient_id} ``` * **`GET` nutrition** — returns the header plus nutrient rows (each with its id); `404` if no panel has been recorded yet. * **`PUT` nutrition** — creates the header if absent, or updates it in place (it is one-per-product, so this is an upsert, not an append). Body: `serving_size`, `serving_size_g`, `servings_per_container`, `label_format`. * **`POST` nutrients** — adds one nutrient row to an existing header. `409` if the nutrient already exists on this panel; `422` if the header doesn't exist yet (create it first via `PUT`). * **`PATCH` / `DELETE` nutrients** — edit or remove a single row by id. Returns `403` for a caller without the contribute permission (no row is created or modified). ## Notes Writes go through an audited `@org_action` service — the direct `form.save()` this dashboard page used before C-4070 is gone; the API path and the UI path now share the exact same permission check and audit trail. # Edit Product Videos Source: https://docs.closient.com/agent-skills/edit-product-videos Add, update, list, and delete a product's YouTube video links — the programmatic equivalent of the dashboard's videos page. Programmatic equivalent of the dashboard's `products//videos/` page (and its `videos/new/`, `videos//edit/`, `videos//delete/` sub-pages). ## When to use * A brand owner needs to attach a YouTube video (demo, unboxing, how-to) to a product, or update/remove one already attached. ## Auth `X-API-Key` header or OAuth token with `dashboard:write` scope. The caller must be a member of `organization_id` and hold the `organization.contribute_organization` permission; the product must be owned by that organization. ## Flow ``` GET /dashboard/api/v1/products/{organization_id}/{gtin}/videos POST /dashboard/api/v1/products/{organization_id}/{gtin}/videos PATCH /dashboard/api/v1/products/{organization_id}/{gtin}/videos/{video_id} DELETE /dashboard/api/v1/products/{organization_id}/{gtin}/videos/{video_id} ``` * **List** — paginated, ordered by ascending `priority`, then most-recent. * **Add** — the URL must be a form a video id can be parsed from (`watch`, `youtu.be`, or `embed` links); other URLs are rejected with `422`. * **Update / Delete** — edit or remove a single row by id; `video_id` must belong to the targeted product. Returns `201` on add, `403` for a caller without the contribute permission, `404` for a GTIN/video not owned by `organization_id`, `422` for an unparsable URL. ## Notes Writes go through an audited `@org_action` service — the direct `form.save()` this dashboard page used before C-4070 is gone. # Find Alternative Source: https://docs.closient.com/agent-skills/find-alternative Find an alternative product that matches user constraints (certifications, substances to avoid, dietary preferences) near a location. User has a product they can't use — wrong certification, contains an allergen, missing a required trait — and needs a substitute available nearby. ## When to use * User says "I can't have X because of Y, find me something else." * Agent receives a recall on a product the user was planning to buy. * Conversational context makes clear the current candidate fails a constraint. ## Inputs to gather 1. **What product are we replacing?** GTIN preferred; free-text fallback. 2. **Why is it unsuitable?** Allergen (peanuts), missing certification (not kosher), substance to avoid (ethanol), dietary stance (not vegan). 3. **Where is the user?** Latitude + longitude. ## Flow ### 1. Get the failing product's attributes ``` GET /products/api/v1/products/{gtin} GET /certifications/api/v1/products/{gtin} # certifications held ``` Capture: GPC brick/category, brand, net\_content range, certifications the user *does* want to preserve. ### 2. Search for candidates with the unified filter Pass the hard constraints directly — the search surface drops failing candidates server-side and stamps each remaining row with inline `certifications` / `contains_allergens` / `substance_flags` / `allergen_data_confidence`. No per-candidate round-trips needed. ``` POST /search/api/v1/search/session { "query": "", "latitude": ..., "longitude": ..., "constraints": { "exclude_allergens": ["peanuts"], "require_certifications": ["vegan", "organic"], "avoid_substances": ["sucralose"] } } ``` Or, for stateless calls, use the catalog endpoint with the same slugs as repeated query params: ``` GET /search/api/v1/search?q=organic+oat+milk &exclude_allergens=peanuts &require_certifications=vegan ``` ### 3. Rank what's left Hard constraints have already filtered the result set. Rank remaining candidates by constraint-fit (PREFER hits boost the rank — see `dietary-filter-search`), then proximity, then price. Inspect each row's `allergen_data_confidence`: `low` means the product hasn't told us anything about allergens, so drop it conservatively when the user has an avoid constraint rather than recommending blindly. ### 4. Return top 3 with reasoning Include why each alternative was selected ("certified vegan, same net content, available at Whole Foods 0.8 km away"). ## Related skills * `dietary-filter-search` — once unified filtering ships, start there instead * `compare-products` — show the user *why* the alternative is better * `check-recalls` — confirm the alternative isn't itself under recall # Hyperlocal Nearby Source: https://docs.closient.com/agent-skills/hyperlocal-nearby Discover what's interesting near the user right now — new arrivals, local-independent retailers, time-sensitive offers. Discovery skill rather than search: given only a location, surface products and stores worth the user's attention *right now*. ## When to use * User asks "what's around me?" or "anything interesting nearby?" * Ambient context (weather, time of day, user history) suggests discovery rather than targeted search. * Agent is filling slow time — e.g. user is waiting at a bus stop — and wants to show something local. ## Inputs to gather * `latitude`, `longitude` — required * `radius_km` — default 2 (hyperlocal; tighter than regular search) * Optional: category hint ("food", "home goods"), time window ## Flow ### 1. Pull nearby stores ``` GET /locations/api/v1/nearby?lat=...&lon=...&radius_km=2 ``` Returns stores with geo distance, categories, and last-updated inventory timestamps. ### 2. Prioritize Score each store by a few signals (all optional; use what's available): * **Local-independent** (not a national chain) — bonus * **Recently updated inventory** (`last_inventory_sync < 1d`) — bonus * **New products added this week** — via offer `created` timestamps * **Popular nearby** — via scan/search activity in the area (when the brand-search-demand view exists publicly; today it's brand-scoped only) ### 3. Sample 3-5 noteworthy products per top store For each top store, get a small sample of offers: ``` GET /retailers/api/v1/organizations/{store_org_id}/in-store-offers?limit=5 ``` Prefer items that are new, seasonal, or match category hints. Skip generic staples unless explicitly relevant. ### 4. Present Return a short list (3-5 stores, 3-5 products each) with reasons ("new this week at your local butcher"). ## Guidance for agents * **Tight radius by default** — "nearby" means walkable. Go wider only if there's genuinely nothing interesting under 2 km. * **Novelty over volume** — the user didn't ask for 50 results. 3 excellent picks beats 30 mediocre ones. * **Respect preferences** — don't surface things the user has AVOID stances against (meat items to vegans, alcohol to non-drinkers). ## Planned * Dedicated `GET /search/api/v1/nearby` endpoint returning a curated discovery feed server-side. * "New this week" facet on the search API. ## Related skills * `local-product-search` — when the user knows *what* they want, use that * `plan-shopping-trip` — if the user turns discovery into purchase intent # Local Product Search Source: https://docs.closient.com/agent-skills/local-product-search Find products available at nearby physical retail stores, ranked by proximity and inventory confidence. Closient returns products at physical stores near a location. Results are ranked by geographic proximity first, then inventory confidence. Relevance is a gate (minimum threshold), not the sort key — if nothing local is relevant, online alternatives surface clearly separated below. ## When to use The user has a product in mind and wants to know where to buy it near them. For identity lookups (barcode → product), use `resolve-gtin`. For deep stock checks on a known GTIN, use `check-product-availability`. ## Endpoint ``` POST /search/api/v1/search/session Content-Type: application/json { "query": "organic oat milk", "latitude": 48.4284, "longitude": -123.3656, "radius_km": 10 } ``` Session-based so conversational follow-ups can refine constraints without re-sending the whole query. ## Required inputs * `query` — free-text * `latitude`, `longitude` — decimal degrees ## Optional constraints * `radius_km` — default 10, max 50 * `in_stock` — only include offers reporting in stock * `certification` — e.g. `organic`, `non-gmo`, `vegan` ## Response Array of hits; each hit has `product` (gtin, name, brand), `store` (name, address, distance\_km), `offer` (price, stock\_status, last\_checked\_at). Take `product.gtin` from any hit and pair with `resolve-gtin` for full detail. ## Refinement `POST /search/api/v1/search/session/{session_id}/refine` with updated constraints. Keeps previous results as context. ## Related skills * `resolve-gtin` — structured product detail for a GTIN from any hit * `check-product-availability` — deeper stock check for a known GTIN * `dietary-filter-search` — search with allergen/dietary constraints # Manage Custom URLs Source: https://docs.closient.com/agent-skills/manage-custom-urls Create, list, update, delete and health-check an organization's custom-URL catalog — the reusable redirect destinations resolution rules point at — the programmatic equivalent of the dashboard's Custom URLs page. Programmatic equivalent of the dashboard's `resolver/custom-urls/` page. The custom-URL catalog holds an organization's reusable redirect destinations; a resolution rule with the `CUSTOM_URL` destination points at one of them. ## When to use * A brand owner wants to add, correct or retire a destination URL (a campaign page, a regional site) before or after pointing rules at it. * A brand owner wants to know whether a destination is healthy. A custom URL on its own redirects nothing. Scans only go there once a rule points at it (see `manage-resolution-rules`). ## Auth `X-API-Key` header or OAuth token with `resolver:write` scope (`resolver:read` for reads). Reading needs view access to the organization; creating, updating, deleting and re-checking need contribute access, and answer `404` without it. ## Flow ``` GET /resolver/api/v1/organizations/{organization_id}/custom-urls POST /resolver/api/v1/organizations/{organization_id}/custom-urls PUT /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id} DELETE /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id} POST /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id}/retry ``` * **`GET` list** — every entry ordered by URL, paginated (`{data, pagination}`), each with `id`, `url`, `status` and `last_checked_at`. * **`POST` create** — body `{"url": "https://..."}`: an absolute `http(s)` address of at most 2048 characters. `409` if the organization already has that address. * **`PUT` update** — body `{"url": ...}`. The entry's health resets to `UNKNOWN` until it is checked again. `409` on an address collision. * **`DELETE`** — `409` while a `CUSTOM_URL` resolution rule still points at the entry; re-point or remove those rules first. * **`POST` retry** — runs a health check now and returns the refreshed entry. ## Health `status` is `UNKNOWN` (never checked), `HEALTHY`, `DEGRADED` (intermittent failures) or `UNREACHABLE`. It is set by the monitor and cannot be written. A rule whose custom URL is unreachable follows its `on_custom_url_dead` policy: skip the rule, or fall back to the Closient-hosted page. ## Verify Re-read the list and `retry` each entry you added or changed until its status is `HEALTHY`. To prove a destination is live for consumers, point a rule at it and resolve a GTIN (MCP tool `resolve_gtin`, or `GET /01/` without following redirects) as described in `manage-resolution-rules`. # Manage Product End of Life Source: https://docs.closient.com/agent-skills/manage-product-end-of-life Add, update, list, and delete a product's end-of-life (disposal and recycling) instructions -- the programmatic equivalent of the dashboard's end-of-life page. Programmatic equivalent of the dashboard's `products//end-of-life/` page (and its add, edit and delete sub-pages). ## When to use * A brand owner needs to tell consumers how to dispose of, recycle or return a product (or one of its components) at end of life. ## Auth `X-API-Key` header or OAuth token. The caller must be a member of the product's organization with the contribute role (Editor or Manager); a read-only key is refused with `403`. A product the caller cannot see, or that another organization owns, answers `404`. ## Flow ``` GET /sustainability/api/v1/end-of-life-instructions POST /sustainability/api/v1/end-of-life-instructions PATCH /sustainability/api/v1/end-of-life-instructions/{record_id} DELETE /sustainability/api/v1/end-of-life-instructions/{record_id} ``` * **List** -- paginated; filter with `?gtin=` (and `?organization_id=`). Read the list first: it shows which rows exist and which fields are empty. * **Create** -- the body names the product with `gtin`; fields are listed in the OpenAPI document at `/sustainability/api/v1/openapi.json`. Returns `201`. * **Update** -- `PATCH` only the fields to change; `null` clears an optional field. * **Delete** -- removes one row; the id must belong to a product the caller can edit. * **Read back** -- every write returns the saved row; `GET` the list again to confirm it shows the change. Returns `403` for a read-only key, `404` for a product or row the caller cannot see, `422` for an invalid value. ## Notes Writes go through the audited `@org_action` sustainability record services: the dashboard page and this API share one write path and one audit record. Fields a brand can write are defined once on the model (`BRAND_EDITABLE_FIELDS`). # Manage Product Environmental Impact Source: https://docs.closient.com/agent-skills/manage-product-environmental-impact Add, update, list, and delete a product's environmental impact assessments (carbon, water, and similar) -- the programmatic equivalent of the dashboard's environmental page. Programmatic equivalent of the dashboard's `products//environmental/` page (and its add, edit and delete sub-pages). ## When to use * A brand owner needs to record, correct or remove an environmental impact figure (type, score, unit, methodology, assessment date) for a product. ## Auth `X-API-Key` header or OAuth token. The caller must be a member of the product's organization with the contribute role (Editor or Manager); a read-only key is refused with `403`. A product the caller cannot see, or that another organization owns, answers `404`. ## Flow ``` GET /sustainability/api/v1/environmental-impacts POST /sustainability/api/v1/environmental-impacts PATCH /sustainability/api/v1/environmental-impacts/{record_id} DELETE /sustainability/api/v1/environmental-impacts/{record_id} ``` * **List** -- paginated; filter with `?gtin=` (and `?organization_id=`). Read the list first: it shows which rows exist and which fields are empty. * **Create** -- the body names the product with `gtin`; fields are listed in the OpenAPI document at `/sustainability/api/v1/openapi.json`. Returns `201`. * **Update** -- `PATCH` only the fields to change; `null` clears an optional field. * **Delete** -- removes one row; the id must belong to a product the caller can edit. * **Read back** -- every write returns the saved row; `GET` the list again to confirm it shows the change. Returns `403` for a read-only key, `404` for a product or row the caller cannot see, `422` for an invalid value. ## Notes Writes go through the audited `@org_action` sustainability record services: the dashboard page and this API share one write path and one audit record. Fields a brand can write are defined once on the model (`BRAND_EDITABLE_FIELDS`). # Manage Product Supply Chain Source: https://docs.closient.com/agent-skills/manage-product-supply-chain Add, update, list, and delete a product's production processes and transport legs -- the programmatic equivalent of the dashboard's supply chain page. Programmatic equivalent of the dashboard's `products//supply-chain/` page (and its production process and transport sub-pages). One page, two record types. ## When to use * A brand owner needs to record where and how a product is made (production processes) or how it moves (transport legs with mode, distance and CO2 estimate). ## Auth `X-API-Key` header or OAuth token. The caller must be a member of the product's organization with the contribute role (Editor or Manager); a read-only key is refused with `403`. A product the caller cannot see, or that another organization owns, answers `404`. ## Flow ``` GET /sustainability/api/v1/process-types (catalog of valid process type codes) GET /sustainability/api/v1/production-processes POST /sustainability/api/v1/production-processes PATCH /sustainability/api/v1/production-processes/{record_id} DELETE /sustainability/api/v1/production-processes/{record_id} GET /sustainability/api/v1/transport-impacts POST /sustainability/api/v1/transport-impacts PATCH /sustainability/api/v1/transport-impacts/{record_id} DELETE /sustainability/api/v1/transport-impacts/{record_id} ``` * **List** -- paginated; filter with `?gtin=` (and `?organization_id=`). Read the list first: it shows which rows exist and which fields are empty. * **Create** -- the body names the product with `gtin`; fields are listed in the OpenAPI document at `/sustainability/api/v1/openapi.json`. Returns `201`. * **Update** -- `PATCH` only the fields to change; `null` clears an optional field. * **Delete** -- removes one row; the id must belong to a product the caller can edit. * **Read back** -- every write returns the saved row; `GET` the list again to confirm it shows the change. Returns `403` for a read-only key, `404` for a product or row the caller cannot see, `422` for an invalid value. ## Notes Writes go through the audited `@org_action` sustainability record services: the dashboard page and this API share one write path and one audit record. Fields a brand can write are defined once on the model (`BRAND_EDITABLE_FIELDS`). # Manage Resolution Rules Source: https://docs.closient.com/agent-skills/manage-resolution-rules Create, read, update, delete and reorder GS1 resolver resolution rules (where a scan of a product, brand or organization is redirected) and read the rule summary — the programmatic equivalent of the dashboard's per-product "Resolver rules" page. Programmatic equivalent of the dashboard's `products//resolver-rules/list/` page and the rule editor behind it. A resolution rule decides where a scan of a GS1 Digital Link (`/01/`) is redirected. ## When to use * A brand owner wants scans of one product (or a whole brand, or the organization) to land somewhere other than the default Closient-hosted page: a campaign URL, a seasonal promotion, a per-country or per-language page. * A brand owner wants to change the order rules are checked in, disable a rule without deleting it, or remove one. ## The contract * **Rules choose destinations; content decides link types.** A product's linkset (the link types it advertises: `pip`, `productInfo`, `ingredientsInfo`, `nutritionalInfo` and so on) is derived from the product's content and its brand's data. Creating, editing, reordering or deleting a redirect rule never adds or removes a content-derived link type. A rule contributes a link type only by pinning one in `link_type` (for example `promotion`, `locationInfo`, `faqs`). If a link type is missing from the linkset, fix the product's content; more rules will not add it. * **Most specific scope first.** Scopes are checked serial, lot, product, brand, organization; within a scope the lowest `order_index` first; the first rule whose criteria all match wins. With no match the scan goes to the brand's Closient-hosted page. * **Criteria narrow a rule.** Link type, time window, country and language are ANDed; an unset criterion constrains nothing. ## Auth `X-API-Key` header or OAuth token with `resolver:write` scope (`resolver:read` for reads). Reading rules needs view access to the organization; creating, updating, deleting and reordering need its change permission, and answer `404` without it. ## Flow ``` GET /resolver/api/v1/organizations/{organization_id}/resolution-rules GET /resolver/api/v1/organizations/{organization_id}/resolution-rules/default GET /resolver/api/v1/organizations/{organization_id}/resolution-rules/summary POST /resolver/api/v1/organizations/{organization_id}/resolution-rules POST /resolver/api/v1/organizations/{organization_id}/resolution-rules/reorder GET /resolver/api/v1/resolution-rules/{rule_id} PUT /resolver/api/v1/resolution-rules/{rule_id} DELETE /resolver/api/v1/resolution-rules/{rule_id} ``` * **`GET` list** — every rule of the organization in evaluation order, paginated (`{data, pagination}`, page size up to 100). Filter with `scope_type` (`ORGANIZATION`, `BRAND`, `PRODUCT`, `BATCH`, `SERIAL`) and `enabled`. * **`GET` default** — the implicit fallback rule (a redirect to the brand's hosted page). It is not a stored row and cannot be edited. * **`GET` summary** — total, active and inactive counts and a per-destination breakdown. * **`POST` create** — body: `scope_type`, `scope_id`, `order_index`, `destination_type` (`HOSTED_PAGE` or `CUSTOM_URL`), `custom_url` (required for `CUSTOM_URL`), `on_custom_url_dead` (`SKIP_RULE` or `FALLBACK_TO_HOSTED`), and optional criteria (`link_type`, `absolute_start_at` / `absolute_end_at`, `annual_start_mmdd` / `annual_end_mmdd`, `days_of_week`, `time_of_day_start` / `time_of_day_end`, `timezone`, `location_countries` / `location_regions` / `location_cities`). `scope_id` names the scoped entity by its id (the 22-character short id or the canonical UUID), never a GTIN, and is not needed at `ORGANIZATION` scope. Returns `201` with the rule, including its `id`. `order_index` must be unused at that scope. * **`PUT` update** — changes only the keys you send; `null` clears a nullable field. * **`DELETE`** — permanent. To keep a rule but stop it applying, `PUT` `enabled: false` instead. * **`POST` reorder** — body `{"rules": [{"id": ..., "order_index": ...}]}`, applied atomically. Any id outside the organization aborts the whole call with `404`. ## Verify Re-read the list and the summary, then resolve the product to see the redirect actually changed: the MCP tool `resolve_gtin` (read-only; it returns `destination_url` and `matched_rule_scope`), or `GET /01/` without following redirects and reading the `Location` of its 307. A rule that is enabled but does not win the resolve check is misordered or its criteria do not match the scan you tested. ## Notes * A `CUSTOM_URL` rule on a product whose brand claim is not verified is refused with `422`: such a product resolves to its Closient-hosted page until the claim is verified. * A rule's `custom_url` is stored as an entry in the organization's custom-URL catalog; see `manage-custom-urls`. * The dashboard's separate "Routing rules" page (conditional query-parameter and path rewrites) and the per-product resolver behavior settings have no API. # Onboard Retailer Source: https://docs.closient.com/agent-skills/onboard-retailer Walk a retailer through connecting their POS, syncing initial inventory, and publishing their first physical store Location on Closient. Closient's local-first search depends on accurate, fresh in-store inventory. This skill takes a retailer Organization from "signed up" to "discoverable in local search" — POS connected, first batch of `InStoreOffer` records synced, and at least one `Location` published with PostGIS coordinates so distance-ranked search can find them. ## When to use * A retailer just signed up and needs to be onboarded. * The agent is helping a retailer add their second / third store location after the first connect. * A retailer's existing POS integration broke and the connection needs re-establishing. ## Auth OAuth token (or API key) with `integrations:write`, `offers:write`, and `locations:write` scopes. The caller must hold `OWNER` or `MANAGER` role on the retailer `Organization`. ## Flow ### Step 1 — Pick a POS integration Closient supports Shopify today, with Square, Clover, and Lightspeed on the roadmap (see **Known gaps**). The agent should ask the retailer which POS they use; if it's not Shopify, route them to the support flow rather than mid-onboarding. ### Step 2 — Connect via OAuth Shopify connect is a **non-API** browser flow today (the OAuth redirect cannot complete inside an agent-only context): ``` GET /integrations/shopify/install?shop=.myshopify.com ``` This redirects to Shopify's OAuth consent screen. After the user approves, Shopify calls back to: ``` GET /integrations/shopify/callback?code=...&shop=...&state=... ``` Closient persists the access token and provisions an `InventorySource` row (in the `retailers` app, not `integrations` — see `apps/integrations/models.py` for the rationale). The agent's job here is to: 1. Generate the install URL with the user's shop domain. 2. Hand the URL to the user (or surface it as a clickable link). 3. Wait for the user to confirm completion, then verify via Step 3. ### Step 3 — Verify the connection ``` GET /integrations/api/v1/webhooks/endpoints/ ``` The provisioned webhook endpoints for the new Shopify connection should appear in the list. If `count == 0` after the user completed the OAuth handshake, the connect didn't land — re-issue Step 2 with a fresh `state` token. ### Step 4 — Sync first batch of inventory Shopify inventory sync is automatic on connection (background task in `apps/integrations/shopify/`). The agent should poll for results: ``` GET /retailers/api/v1/organizations/{organization_id}/in-store-offers ``` The first sync typically takes 30-120 seconds for catalogs under 1k SKUs. The agent should report progress to the user with the count returned. Manual offer creation (for retailers who want to bootstrap without a POS) is also supported: ``` POST /retailers/api/v1/organizations/{organization_id}/in-store-offers Content-Type: application/json { "product_id": "", "physical_store_id": "", "sku": "...", "price_amount": 1099, "price_currency": "USD", "quantity_on_hand": 12, "status": "in_stock" } ``` The `(product_id, physical_store_id, sku)` triple is unique — duplicates return `422`. ### Step 5 — Publish the first Location Local search ranks by great-circle distance using PostGIS, so a Location with accurate `lat`/`lon` is required for the retailer to appear in nearby-store queries. Today this is done via the dashboard UI (`/organizations/create` and the org-scoped location form) — there is no `POST /locations/api/v1/locations` endpoint yet (see **Known gaps**). The agent should: 1. Direct the user to the dashboard location form. 2. Once published, verify via the public endpoint: ``` GET /locations/api/v1/locations?lat=&lon=&radius=1 ``` The retailer's location should appear with `distance_km` near zero. ### Step 6 — Confirm discoverability ``` POST /search/api/v1/search/session Content-Type: application/json { "query": "", "latitude": , "longitude": , "radius_km": 5 } ``` The retailer's store should appear in results within a few minutes of the inventory sync completing. If it doesn't, the most common cause is zero published `Location` rows for the org — re-verify Step 5. ## Required inputs * `organization_id` — the retailer's organization. * `shop_domain` — `.myshopify.com` (Shopify-only today). * One physical store address (city, state/region, postal code) for Step 5. ## Optional inputs * `latitude` / `longitude` — if the retailer already knows them; else the dashboard form geocodes from the address. * Store hours — populated via the dashboard location form. * Webhook endpoints — provisioned automatically; can be listed via `GET /integrations/api/v1/webhooks/endpoints/`. ## Output * `integration_status` — `connected` once Step 3 verifies; `pending` while waiting for OAuth completion. * `synced_offer_count` — from the count returned by the in-store-offers list endpoint in Step 4. * `first_location_url` — the public location URL once Step 5 completes (form path: `/marketplace//locations/`). ## Guidance for agents * **Surface the dashboard handoff explicitly.** Steps 2 (OAuth) and 5 (Location create) are not fully API-driven today — the user has to click a link / fill a form. Be honest about this rather than pretending the agent can complete them headlessly. * **Don't poll faster than 5 seconds** on the in-store-offers list — Shopify's catalog sync is rate-limited and we throttle the public API at 300 req/min/key. A patient backoff is more user-friendly than flooding the API. * **One Location at minimum, but encourage all of them.** Local search ranks by proximity per-location, so a multi-store retailer that only publishes one location is invisible to the other neighborhoods. * **Inventory freshness matters more than completeness.** Surface the `last_inventory_sync` field on offers so the user knows their data is current — stale inventory ranks lower in search. * **Webhook secret.** The OAuth flow provisions endpoints with a rotating secret. If the retailer needs to verify webhook deliveries on their side, the secret is available via `GET /integrations/api/v1/webhooks/endpoints/{endpoint_id}` and can be rotated via `POST /webhooks/endpoints/{endpoint_id}/rotate-secret/`. ## Known gaps (planned) * **Shopify-only today.** The original ticket scope mentions Square, Clover, and Lightspeed. Models reference these as future `InventorySource` types but no OAuth flow exists. Planned per app in `apps/integrations/` — track via the integrations roadmap. * **No headless POS-connect endpoint.** Step 2 requires a browser redirect today. A device-code OAuth flow (so agents can complete the connect on behalf of a user with a one-time code) is planned. * **No `POST /locations/api/v1/locations`.** Step 5 is currently a dashboard form. A first-class Location create endpoint (with PostGIS coordinates, hours, contact info) is planned so this skill can be fully API-driven end-to-end. * **No "is my store discoverable?" health endpoint.** Step 6 uses the search endpoint as an indirect probe. A dedicated readiness check (POS connected + ≥1 location published + ≥1 in-stock offer) would be cleaner. * **No sync-job status endpoint.** The agent has to poll the offer list to infer sync progress. A `GET /integrations/api/v1/jobs/{job_id}` is planned. ## Related skills * `claim-brand` — the parallel flow for brand-owner Organizations. * `local-product-search` — the surface this onboarding enables — what consumers see once the retailer is live. * `check-product-availability` — how agents probe a specific GTIN against the retailer's now-synced offers. * `brand-retail-footprint` — once a retailer is live, this skill (called from a brand owner's side) shows their products being carried. # Agent Skills Source: https://docs.closient.com/agent-skills/overview What an AI agent can do with Closient — a discoverable, version-pinned set of skills published at /.well-known/agent-skills/. Closient publishes a set of **Agent Skills** — short, focused capability descriptions that an AI agent can fetch to learn how to drive a specific Closient workflow. Each skill is a markdown document covering when to use it, which endpoint backs it, the inputs it requires, what the response looks like, and which related skills round out the workflow. Skills are discoverable at `/.well-known/agent-skills/` per the [Agent Skills v0.2.0 specification](https://agentskills.io/). The [index document](https://www.closient.com/.well-known/agent-skills/index.json) lists every skill with its content hash so agents can cache safely. The pages below render the same source content in a human-friendly form. They are the canonical reference for **what Closient can be asked to do** — useful for product managers scoping integrations, developers picking an endpoint, and brand owners deciding what to expose. ## Consumer skills Skills aimed at the everyday consumer-facing flow: find a product, see if it is nearby, see whether it has been recalled, suggest an alternative if it has. Find products available at nearby physical retail stores, ranked by proximity and inventory confidence. Surface the closest stores carrying a known product within a tight walking-or-cycling radius. Confirm whether a specific GTIN is in stock at nearby stores right now. Look up active recalls and safety notices for a product, optionally narrowed to a batch/lot. Recommend an alternative product when the requested one is unavailable, unsuitable, or recalled. Compare two or more products on certifications, allergens, substances, claims, and price. Plan a single shopping trip that collects everything on a list from the fewest nearby stores. Search with allergen, certification, and dietary-restriction constraints enforced at the result level. ## Identifier skills Skills that resolve, decode, or look up structured identifiers — GS1 GTINs, serials, batch/lot codes — back to product information. Resolve a GS1 Digital Link URL or bare GTIN to structured product detail, with optional AI 10 / AI 21 qualifiers. ## Brand-owner skills Skills scoped to a brand-owner persona — these power the dashboards a brand sees when they ask "what are agents doing with my products?" Summarize scan activity for a brand's products: where, when, how often, which lots. Show where demand for a brand's products is coming from — queries, regions, competitive comparisons. Where a brand's products are actually being carried in physical retail, not just where they list distribution. ## How to consume these as an agent ```http theme={null} GET /.well-known/agent-skills/index.json ``` Returns the canonical index with content hashes. Fetch any individual skill at `/.well-known/agent-skills/{slug}.md` and treat the markdown as your instructions for that capability. Agents that prefer a richer discovery surface should also pull the [Agent Card](/.well-known/agent.json) and the [MCP descriptor](/.well-known/mcp.json) — both link to the same skills index. Brand owners can also hand an agent a ready-made task from the dashboard. See [Hand work to your AI agent](/guides/agent-handoff). # Plan Shopping Trip Source: https://docs.closient.com/agent-skills/plan-shopping-trip Given a shopping list and a user location, figure out the minimum number of stores needed and a sensible order to visit them. User has a list of items and wants to know where to go. This skill minimizes stops while respecting stock, distance, and store hours. ## When to use * User provides 2+ items and asks "where should I go?" * User wants a route, not just a single-store recommendation. * Shopping is local (within a few km of the user's location). For a single item, use `check-product-availability` directly. ## Inputs to gather 1. **Shopping list** — product names or GTINs (mixed is fine). 2. **Origin location** — latitude + longitude. 3. **Max distance / budget** — optional `radius_km` (default 10), optional `max_stops` (default 3). 4. **Preferences** — optional: in-stock only, avoid certain stores, prefer local-independent. ## Flow ### 1. Resolve each item to candidate GTINs For items already given as GTINs, skip. For free-text, call `local-product-search` per item to get top candidates with per-store availability. ### 2. Build a coverage matrix Rows = stores within radius. Columns = items. Cell = `(available, price, confidence)`. Drop stores where no items are available. ### 3. Pick the minimum cover Greedy set-cover works fine for small lists: pick the store covering the most items, repeat on the remaining list. Tie-break by distance, then total price. ### 4. Order the stops Sort picked stores by distance from origin, then by travel time between consecutive stores. If `max_stops` is exceeded, relax "in-stock only" before dropping items. ### 5. Return the plan ```json theme={null} { "stops": [ { "store": {"name": "...", "address": "...", "distance_km": 0.8}, "items": [{"gtin": "...", "product_name": "...", "price": 4.99}], "open_now": true }, ... ], "unavailable": [{"item": "fresh dragonfruit", "reason": "out of stock within 10km"}] } ``` ## Guidance for agents * **Always state what's unavailable** — users need to know what they'll have to pick up elsewhere or skip. * **Respect constraints explicitly**: if the user said "no Whole Foods," exclude that chain even if it gives a better cover. * **Prefer fewer stops over lowest total price** unless the user says otherwise — time is usually the real constraint. ## Planned A `POST /search/api/v1/trip-plan` endpoint that runs the optimization server-side would let agents hand off the whole computation. For now, the agent drives the loop. ## Related skills * `local-product-search` — drives candidate resolution per item * `check-product-availability` — per-store verification for a GTIN * `hyperlocal-nearby` — add opportunistic items the user didn't ask for # Query EPCIS Events Source: https://docs.closient.com/agent-skills/query-epcis-events Query the GS1 EPCIS 2.0 event ledger for the authenticated organization to reconstruct chain-of-custody for a GTIN, batch, location, or time window, returning a JSON-LD EPCISQueryDocument with cursor pagination. EPCIS 2.0's `SimpleEventQuery` (§ 8.2.7 / § 8.2.8) is the standard way to ask "what happened to this EPC?" across the captured ledger. Closient implements it at `GET /epcis/api/2.0/events`, scoped to the authenticated organization. This skill walks an agent through building filter combinations that reconstruct chain-of-custody, verify a recall, or audit a transformation step. ## When to use * A brand owner asks "where did batch X go?" — walk forward from `bizStep=shipping` events. * A retailer needs to confirm a receive against the supplier's shipping CTE — walk backward from `bizStep=receiving`. * A compliance audit needs every CTE-relevant event for a GTIN in a date window for FSMA 204 § 1.1455(c) records request. * Verify a recall: does the recalled batch have a corresponding `disposition=recalled` event in the ledger? For full chain traversal with downstream impact counts, prefer the dedicated traversal endpoints: `GET /epcis/api/2.0/traceability/lots/{lot_id}/fda-export` (FDA Sortable Spreadsheet export) and `GET /epcis/api/2.0/traceability/lots/{lot_id}/blast-radius` (quantified downstream footprint). This skill is for raw event-level queries. ## Auth OAuth token (or `X-API-Key`) with `OWNER` or `MANAGER` membership on the organization. Results are scoped to events captured by that organization — cross-org leakage is blocked by the org filter on the server, not by client-side filtering. ## Flow ### Step 1 — Pick filter dimensions The endpoint accepts the GS1 EPCIS 2.0 `SimpleEventQuery` parameter set. Most agents need one or two dimensions: | Param | Type | Use case | | - | - | - | | `eventType` | one of `ObjectEvent` / `AggregationEvent` / `TransactionEvent` / `TransformationEvent` / `AssociationEvent` | Limit to e.g. only transformations. | | `GE_eventTime` | RFC 3339 timestamp | "Since when?" lower bound (inclusive). | | `LT_eventTime` | RFC 3339 timestamp | "Up to when?" upper bound (exclusive). | | `EQ_bizStep` | CBV BizStep token or canonical URI | "Show me all shipping events". | | `EQ_disposition` | CBV Disposition token or canonical URI | "Show me everything currently `in_transit`" or `recalled`. | | `EQ_readPoint` | GS1 Digital Link `/414/{gln}` or `urn:epc:id:sgln:` | Events observed at one location. | | `EQ_bizLocation` | same | Events whose business location is one site. | | `MATCH_epc` | single EPC URI (Digital Link or SGTIN URN) | All events touching one EPC. | | `MATCH_anyEPC` | comma-separated EPC URIs | Events touching any of N EPCs. | | `EQ_action` | `ADD` / `OBSERVE` / `DELETE` | Narrow to a single action verb. | | `perPage` | integer | Page size (server default applies). | | `nextPageToken` | opaque cursor | Set for you in the `Link` next-page URL (Step 4). | ### Step 2 — Issue the query ```http theme={null} GET /epcis/api/2.0/events?MATCH_epc=https%3A%2F%2Fid.gs1.org%2F01%2F00614141123456%2F10%2FLOT-2026-042&EQ_bizStep=shipping Authorization: Bearer ``` URL-encode EPC URIs — the `?` / `/` / `%` in Digital Link forms are ambiguous without percent encoding. ### Step 3 — Read the response The body is a JSON-LD `EPCISQueryDocument` (GS1 EPCIS 2.0 § 8.2.5): ```json theme={null} { "@context": ["https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"], "type": "EPCISQueryDocument", "epcisBody": { "queryResults": { "eventList": [ { "type": "ObjectEvent", "eventTime": "2026-05-18T14:30:00Z", "eventTimeZoneOffset": "+00:00", "epcList": ["https://id.gs1.org/01/00614141123456/10/LOT-2026-042"], "action": "OBSERVE", "bizStep": "shipping", "disposition": "in_transit", "readPoint": {"id": "https://id.gs1.org/414/0614141000005"} } ] } } } ``` Headers include `GS1-EPCIS-Version: 2.0.0` and `GS1-CBV-Version: 2.0.0`, and, when more results exist, the pagination headers below. ### Step 4 — Follow the `Link` header Pagination follows the GS1 EPCIS 2.0 REST binding: while more results remain, the response carries a `Link` header (RFC 8288) whose `rel="next"` target is the absolute URL of the next page, with your original query parameters and a `nextPageToken` already set. Request that URL verbatim. When `Link` is absent, the result set is exhausted. ```http theme={null} Link: >; rel="next" GS1-Next-Page-Token-Expires: 2026-10-02T13:00:00Z ``` `GS1-Next-Page-Token-Expires` is the time until which that token is guaranteed valid. The older `GS1-Next-Page-Token` header (the bare cursor) is still sent but is deprecated and will be removed; read `Link` instead. ### Step 5 — Fetch a single event by ID ```http theme={null} GET /epcis/api/2.0/events/{event_id} ``` `event_id` is the 36-char canonical UUID assigned at capture (echoed back as `eventID` on each event). Other organizations receive `404` — no leak of existence. ## Required inputs None at the protocol level (an empty query returns the org's events in `-eventTime` order, paginated). Practically: at least one filter (`MATCH_epc` or `GE_eventTime`) to keep results bounded. ## Optional inputs All `SimpleEventQuery` params per the Step 1 table. ## Output `200 OK` JSON-LD `EPCISQueryDocument` with: * `@context` — EPCIS 2.0 context URI. * `type` — always `EPCISQueryDocument`. * `epcisBody.queryResults.eventList` — array of events (newest first). * `Link` header with `rel="next"` — present when more pages exist. Error responses follow RFC 9457 Problem Details (`application/problem+json`): * `401` — missing or invalid auth. * `403` — caller is not `OWNER` / `MANAGER`. * `422` — malformed query parameters (e.g. unparsable timestamp). * `429` — rate limited (300 req/min, 10k req/day per key). ## Guidance for agents * **Read `bizStep` like a verb.** The CBV vocabulary is human-readable on purpose: `commissioning` (born), `harvesting` (picked), `cooling`, `packing`, `shipping`, `receiving`, `retail_selling`, `dispensing`, `destroying`. Reconstruct narrative by sorting events by `eventTime` and reading the bizStep column. * **Disposition is current-state, bizStep is verb.** A `shipping` event with `disposition=in_transit` means "we shipped it; it's currently in transit." A subsequent `receiving` with `disposition=available` means "the receiver took possession; it's now available at their location." A `disposition=recalled` event is what `check-recalls` looks for when answering "is this lot under recall?". * **`MATCH_epc` is exact-string.** The server matches the full EPC URI literally — `https://id.gs1.org/01/00614141123456/10/LOT-A` and `https://id.gs1.org/01/00614141123456/10/LOT-A/21/0001` are different EPCs. Use `MATCH_anyEPC` (comma-separated) when you have multiple serialization candidates. * **Time windows narrow first.** A query with no time bound has to scan the whole org's event table. Always include `GE_eventTime` (and `LT_eventTime` when chasing a specific incident window) before fanning out to other dimensions. * **Chain reconstruction is sort-then-walk.** EPCIS doesn't natively give you the parent/child traversal that FSMA 204 expects (that lives on the materialized CTE rows). For a single-lot story, sort the returned events by `eventTime` and read the bizStep column; for full traversal of a CTE chain, use the `/traceability/lots/{lot_id}/fda-export` endpoint. * **Don't reconstruct blast radius from this endpoint.** Use `GET /epcis/api/2.0/traceability/lots/{lot_id}/blast-radius` — it walks the materialized CTE graph and returns aggregate counts in one call. Doing it by hand from event-level data is brittle (no parent/child linkage on raw events) and wastes API quota. ## Known gaps (planned) * **No `MASTER_DATA` query.** EPCIS 2.0 § 8.2.9 defines a master-data query (vocabulary lookup for biz locations, read points, etc.) — Closient does not implement it. Use the management API (`/epcis/api/v1/trading-partners`, `/epcis/api/v1/reference-documents`) for master data CRUD instead. * **No subscription / poll endpoint.** EPCIS 2.0 § 8.3 standing-query subscription is not implemented. Agents must poll. * **No `MATCH_epcClass` filter.** Class-level matches (all events on any EPC sharing a GTIN, regardless of lot/serial) require a pre-filter by `MATCH_anyEPC` or a post-query group-by on `epcList`. Planned alongside FSMA 204 GTIN-keyed dashboards. * **No `EQ_quantity` filter.** Quantity-list-based filtering (events involving > N units) is out of scope for the standard `SimpleEventQuery` and not supported. * **Cursor opacity is server-internal.** The `nextPageToken` is not the GS1-suggested base64-encoded `eventID|eventTime` form — it's a Closient-internal opaque cursor. Tokens are not durable across schema migrations. ## Related skills * `capture-epcis-event` — write-side; produces the rows this skill reads. * `check-fsma-204-readiness` — uses query results to determine which KDEs are present per lot. * `manage-recall` — checks for `disposition=recalled` events when reviewing a recall record. * `check-recalls` — consumer side; resolves "is GTIN X under recall right now?" via `disposition=recalled` (joined with the regulator recall feed). # Resolve GTIN Source: https://docs.closient.com/agent-skills/resolve-gtin Resolve a GTIN (8/12/13/14-digit product barcode) via GS1 Digital Link to structured product information. Closient is a GS1-conformant Digital Link resolver. A GTIN can be resolved to structured product data, a consumer product information page, warranty info, recall status, and more. ## When to use * The agent has a GTIN (or scanned barcode) and needs product detail. * The agent received a GTIN from `local-product-search` and wants the full product record. ## Two ways in ### REST (recommended for agents) ``` GET /products/api/v1/products/{gtin} Accept: application/json ``` Returns the full `ProductOut` schema: product\_name, brand, manufacturer, image\_url, description, net\_content, GPC taxonomy, dimensions, weight, country\_of\_origin, data quality signals (confidence\_score, field\_confidence), ownership state (is\_claimed, claim\_verification\_status). ### GS1 Digital Link (consumer page or linkset) ``` GET /01/{gtin} Accept: text/html # consumer product information page Accept: application/json # structured JSON Accept: application/linkset+json # all available link types ``` ## Qualifier paths * `/01/{gtin}/10/{batch}` — resolve with batch/lot identifier * `/01/{gtin}/21/{serial}` — resolve with serial number * `?linkType=gs1:pip` / `gs1:warranty` / `gs1:recallStatus` — specific link types ## The `safety` block The JSON and JSON-LD representations of `/01/{gtin}` (and any qualifier path under it) always carry a `safety` object. It is the per-scan safety verdict, evaluated from the same gate a human scanning the same URI is stopped by, so a machine and a person cannot be given different answers. ```json theme={null} "safety": { "recallStatus": "none", "expiryStatus": "none" } ``` ### `recallStatus` — three values, and they are not interchangeable | Value | Meaning | What an integrator should do | | - | - | - | | `active` | An open recall matches this scan. Lot (AI 10), serial (AI 21) and date (AI 15/17) qualifiers are taken into account, so this is a verdict about *the unit in hand*, not about the GTIN. | Surface it prominently. Do not redirect the user onward without showing it. | | `none` | No open recall matched, **and** every government source that regulates this product has delivered recall data inside its freshness window. | Treat as a clearance. This is the only value that is one. | | `indeterminate` | No open recall matched, **but** at least one governing source has not delivered recently — including a source that has never once succeeded. We do not know. | Do **not** present as safe. Say the status could not be confirmed, and point the user at the regulator's own listing. | `indeterminate` exists because the absence of a recall record has two very different causes — "we looked and there is nothing" and "we have nothing to look in" — and until it existed both were reported as `none`. Sources are polled independently (FDA, FSIS, CPSC, Health Canada), so one dark feed degrades only the products it regulates; a product whose category we have not classified is treated as governed by all of them. The key is never omitted. An absent field is indistinguishable from a resolver that does not implement it, so "unknown" is a value rather than a silence. `GET /pip/{gtin}/recall/` is the human-readable counterpart and renders the same three states — including an explicit "recall status unconfirmed" page, which names the sources that could not be checked. `Accept: text/markdown` returns it as structured markdown with the same `recallStatus` value. `gs1:recallStatus` in the linkset is a **link relation, not a verdict**: it is advertised when there is an open recall to read. Its absence is not a clearance — read `safety.recallStatus` for the answer. ## What's NOT in the response Certifications, allergens, and recall data live in separate APIs. Use: * `check-recalls` for recall/safety notice status * `/certifications/api/v1/...` for product certifications ## Related skills * `check-recalls` — active recalls for this GTIN * `compare-products` — diff two GTINs side-by-side * `local-product-search` — find where to buy this GTIN near you # API Reference Source: https://docs.closient.com/api-overview Explore 18 domain-specific APIs with interactive playgrounds. Closient exposes 18 RESTful APIs, each with its own OpenAPI specification and interactive playground. Browse the full reference in the **[API Reference](/api-reference/overview)** tab above. ## Most Used APIs GTIN-based product catalog with automatic normalization, data provenance, and confidence scores. Hybrid search combining semantic embeddings, lexical matching, and geographic proximity. Resolve GTINs via Digital Link URIs with configurable scope hierarchy and time-based routing. POS connections (Shopify, Square, Clover, Lightspeed) and webhook management. ## All APIs | API | Description | | - | - | | [Account](/api-reference/account) | Users, organizations, memberships, API keys, RBAC | | [Products](/api-reference/products) | GTIN product catalog, images, categories, groups | | [Brands](/api-reference/brands) | Brand ownership, claiming, verification | | [Locations](/api-reference/locations) | Physical locations with PostGIS coordinates | | [Retailers](/api-reference/retailers) | Offers, inventory, pricing, regional overrides | | [Search](/api-reference/search) | Hybrid search with conversational sessions | | [GS1 Digital Link](/api-reference/resolver) | GTIN resolution, scope hierarchy, routing rules | | [EPCIS 2.0](/api-reference/epcis) | Supply chain events per ISO/IEC 19987:2024 | | [Integrations](/api-reference/integrations) | POS connections, webhooks, delivery tracking | | [Billing](/api-reference/billing) | Subscriptions, resolver tiers, usage metering | | [Scanner](/api-reference/scanner) | Barcode and image scanning, vision extraction | | [Compliance](/api-reference/compliance) | Recalls, safety notices, traceability | | [Certifications](/api-reference/certifications) | Certification management and verification | | [Sustainability](/api-reference/sustainability) | Environmental data and certifications | | [Notifications](/api-reference/notifications) | Multi-channel delivery (email, Slack, SMS) | | [Dashboard](/api-reference/dashboard) | Analytics and performance snapshots | | [Content](/api-reference/content) | Product readiness and content management | Interactive playgrounds, request/response examples, and complete parameter documentation for all 18 APIs. # Identify the authenticated caller Source: https://docs.closient.com/api-reference/account/account/identify-the-authenticated-caller /openapi/openapi-account.json get /account/api/v1/whoami Returns the caller identity, their organizations, and the API key used for this request. # Create API key Source: https://docs.closient.com/api-reference/account/api-keys/create-api-key /openapi/openapi-account.json post /account/api/v1/organizations/{organization_id}/api-keys Create a new API key for the organization. Requires MANAGE_API_KEYS permission. # Delete API key Source: https://docs.closient.com/api-reference/account/api-keys/delete-api-key /openapi/openapi-account.json delete /account/api/v1/organizations/{organization_id}/api-keys/{key_id} Delete an API key. Requires MANAGE_API_KEYS permission. # Get API key Source: https://docs.closient.com/api-reference/account/api-keys/get-api-key /openapi/openapi-account.json get /account/api/v1/organizations/{organization_id}/api-keys/{key_id} Return one API key's metadata. The key value itself is only ever shown once, at creation. Requires MANAGE_API_KEYS permission. # List API keys Source: https://docs.closient.com/api-reference/account/api-keys/list-api-keys /openapi/openapi-account.json get /account/api/v1/organizations/{organization_id}/api-keys List all API keys for the organization. Requires MANAGE_API_KEYS permission. # Update API key Source: https://docs.closient.com/api-reference/account/api-keys/update-api-key /openapi/openapi-account.json patch /account/api/v1/organizations/{organization_id}/api-keys/{key_id} Update an API key's name or description. Requires MANAGE_API_KEYS permission. # Get enrollment code Source: https://docs.closient.com/api-reference/account/enrollment-codes/get-enrollment-code /openapi/openapi-account.json get /account/api/v1/enrollment-codes/{enrollment_code_id} Return one of the authenticated consumer's enrollment codes and its redemption status. The code value itself is not included. # Get live enrollment code Source: https://docs.closient.com/api-reference/account/enrollment-codes/get-live-enrollment-code /openapi/openapi-account.json get /account/api/v1/enrollment-codes/live/{retailer_id} Return the authenticated consumer's current live enrollment code for a retailer, if any. # List enrollment codes Source: https://docs.closient.com/api-reference/account/enrollment-codes/list-enrollment-codes /openapi/openapi-account.json get /account/api/v1/enrollment-codes List every enrollment code the authenticated consumer has minted, newest first, with its redemption status. The code value itself is not included: re-display a still-live code through `GET /enrollment-codes/live/{retailer_id}`. # Mint enrollment code Source: https://docs.closient.com/api-reference/account/enrollment-codes/mint-enrollment-code /openapi/openapi-account.json post /account/api/v1/enrollment-codes Mint a single-use enrollment code for the authenticated consumer, scoped to one retailer. Minting invalidates any still-live code already minted for that retailer. # Revoke enrollment code Source: https://docs.closient.com/api-reference/account/enrollment-codes/revoke-enrollment-code /openapi/openapi-account.json delete /account/api/v1/enrollment-codes/{enrollment_code_id} Revoke one of the authenticated consumer's own enrollment codes before it is redeemed. # Get organization member Source: https://docs.closient.com/api-reference/account/members/get-organization-member /openapi/openapi-account.json get /account/api/v1/organizations/{org_id}/members/{member_id} Get a specific member of an organization by membership ID. # Invite a member Source: https://docs.closient.com/api-reference/account/members/invite-a-member /openapi/openapi-account.json post /account/api/v1/organizations/{org_id}/members Invite a user to the organization by email. Creates a pending, single-use invite (C-3818) — no membership is granted until the invitee redeems it. # List organization members Source: https://docs.closient.com/api-reference/account/members/list-organization-members /openapi/openapi-account.json get /account/api/v1/organizations/{org_id}/members List all members of an organization. # Remove a member Source: https://docs.closient.com/api-reference/account/members/remove-a-member /openapi/openapi-account.json delete /account/api/v1/organizations/{org_id}/members/{member_id} Remove a member from the organization. # Update member role Source: https://docs.closient.com/api-reference/account/members/update-member-role /openapi/openapi-account.json put /account/api/v1/organizations/{org_id}/members/{member_id} Update a member's role within the organization. # Create organization Source: https://docs.closient.com/api-reference/account/organizations/create-organization /openapi/openapi-account.json post /account/api/v1/organizations Create a new organization for the authenticated user. A signed-in caller becomes its OWNER; an API-key caller must send `issue_api_key: true` and receives the new organization's own manager key, because a key acts only on the organization it belongs to. A data-retention policy is created for it (FSMA 204 requires one before any traceability record can be written), Stripe provisioning for the FREE tier is queued, and an analytics lifecycle event is emitted. No catalog onboarding happens — no product, brand or GTIN claim is created — so the organization is ready to invite members immediately. A staff account or the catalog-import service account may set `provision_billing: false` to skip the Stripe task when seeding organizations in bulk; see that field. Any other caller sending it is rejected with 403, and a service account that omits it entirely is rejected with 422 — a script has to state its billing intent rather than inherit a default meant for interactive callers. An organization created with the importer credential is marked as importer-provisioned: that credential may keep acting inside it, naming it in `X-Closient-Organization`, until a human owns it. # Delete organization Source: https://docs.closient.com/api-reference/account/organizations/delete-organization /openapi/openapi-account.json delete /account/api/v1/organizations/{id} Schedule an organization for deletion (a signed-in owner, or a manager-role API key of this organization). This is a reversible 90-day tombstone: the catalog is unclaimed (brands, locations, and products survive as crowd-sourced), the Stripe subscription is cancelled at period end, and API keys are revoked immediately. Regulated traceability records are retained. The org can be restored within the grace window, after which its org-only data is purged. Returns 204 No Content on success. When the organization has `require_reauth_to_delete` enabled (the default), a *browser-session* caller must have re-authenticated at their identity provider within the freshness window or the request is rejected with 403 — a stolen session cookie alone cannot delete. An API key may delete only the organization it belongs to, and only with the manager role; editor and read-only keys, and OAuth tokens, are refused. The key stops working the moment the organization is tombstoned, along with every other key of that organization. API-key callers are not subject to the re-authentication gate. # Fast-purge organization Source: https://docs.closient.com/api-reference/account/organizations/fast-purge-organization /openapi/openapi-account.json post /account/api/v1/organizations/{id}/fast-purge Owner-only override of the default 90-day tombstone lifecycle (see `DELETE /organizations/{id}`): immediately and irreversibly destroys the organization and all its data, including regulated traceability records that would otherwise be retained per FSMA 204 / DSCSA. Requires typing the organization's exact current name as `confirmation_name` and setting `acknowledged: true` to explicitly waive the regulated-data retention window. Returns `409` if any product this org touches is under an active recall (spoliation guard), or `422` if the confirmation input doesn't match. Subject to the same step-up re-authentication gate as `DELETE /organizations/{id}`. This cannot be undone. # Get organization Source: https://docs.closient.com/api-reference/account/organizations/get-organization /openapi/openapi-account.json get /account/api/v1/organizations/{id} Retrieve a single organization by ID. # List all organizations Source: https://docs.closient.com/api-reference/account/organizations/list-all-organizations /openapi/openapi-account.json get /account/api/v1/organizations List all organizations that the user has access to # Update organization Source: https://docs.closient.com/api-reference/account/organizations/update-organization /openapi/openapi-account.json put /account/api/v1/organizations/{id} Update an organization's details. # Create a trait preference Source: https://docs.closient.com/api-reference/account/preferences/create-a-trait-preference /openapi/openapi-account.json post /account/api/v1/preferences Record the caller's stance on one trait. ``404`` if no trait has that slug; ``409`` if the caller already holds a preference on it (update that one instead); ``422`` for a non-``AVOID`` stance on a ``SENSITIVITY`` trait. # Delete a trait preference Source: https://docs.closient.com/api-reference/account/preferences/delete-a-trait-preference /openapi/openapi-account.json delete /account/api/v1/preferences/{preference_id} Remove one of the caller's preferences. ``404`` if it is not the caller's. # List the caller's stored trait preferences Source: https://docs.closient.com/api-reference/account/preferences/list-the-callers-stored-trait-preferences /openapi/openapi-account.json get /account/api/v1/preferences Returns the caller's full ``TraitPreference`` list — allergens to avoid, certifications required, dietary stances. Fresh accounts get a ``200`` with an empty list. Each row's ``trait`` slug is normalized lower-snake-case so it can be passed directly into ``POST /search/api/v1/search/session`` constraints (``exclude_allergens`` for ALLERGEN-category rows; ``require_certifications`` for DIETARY/RELIGIOUS/SOURCING rows). # Update a trait preference Source: https://docs.closient.com/api-reference/account/preferences/update-a-trait-preference /openapi/openapi-account.json patch /account/api/v1/preferences/{preference_id} Change the stance on one of the caller's preferences. ``404`` if it is not the caller's; ``422`` for a non-``AVOID`` stance on a ``SENSITIVITY`` trait. # Enable or disable retailer client Source: https://docs.closient.com/api-reference/account/retailer-clients/enable-or-disable-retailer-client /openapi/openapi-account.json patch /account/api/v1/organizations/{organization_id}/retailer-clients/{client_id} Enable or disable a retailer client. Disabling is not deletion: the client, its links and its declared coverage survive. Requires MANAGE_RETAILER_CLIENTS permission. # List retailer clients Source: https://docs.closient.com/api-reference/account/retailer-clients/list-retailer-clients /openapi/openapi-account.json get /account/api/v1/organizations/{organization_id}/retailer-clients List the organization's retailer clients. Secrets are never included. Requires MANAGE_RETAILER_CLIENTS permission. # Register retailer client Source: https://docs.closient.com/api-reference/account/retailer-clients/register-retailer-client /openapi/openapi-account.json post /account/api/v1/organizations/{organization_id}/retailer-clients Register a retailer client and issue its first secret. The secret is returned in this response only. Requires MANAGE_RETAILER_CLIENTS permission. # Rotate retailer client secret Source: https://docs.closient.com/api-reference/account/retailer-clients/rotate-retailer-client-secret /openapi/openapi-account.json post /account/api/v1/organizations/{organization_id}/retailer-clients/{client_id}/rotate-secret Issue a new secret for a retailer client. The previous secret stops working immediately and there is no overlap window. The new secret is returned in this response only. Requires MANAGE_RETAILER_CLIENTS permission. # Delete purchase history Source: https://docs.closient.com/api-reference/account/retailer-links/delete-purchase-history /openapi/openapi-account.json post /account/api/v1/retailer-links/{link_id}/delete-history Explicitly delete purchase history associated with this link. Independent of unlinking in both directions. Recall coverage for the deleted purchases ends. Idempotent. # Get retailer link detail Source: https://docs.closient.com/api-reference/account/retailer-links/get-retailer-link-detail /openapi/openapi-account.json get /account/api/v1/retailer-links/{link_id} Full detail for one of the authenticated consumer's own retailer links. # List retailer links Source: https://docs.closient.com/api-reference/account/retailer-links/list-retailer-links /openapi/openapi-account.json get /account/api/v1/retailer-links The authenticated consumer's own retailer links, top-level view only. # Unlink retailer Source: https://docs.closient.com/api-reference/account/retailer-links/unlink-retailer /openapi/openapi-account.json post /account/api/v1/retailer-links/{link_id}/unlink Unlink from a retailer. Purchases already associated with this link are retained for recall coverage -- unlinking never deletes them. Idempotent. # Capture an anonymous scan Source: https://docs.closient.com/api-reference/account/scan-history/capture-an-anonymous-scan /openapi/openapi-account.json post /account/api/v1/history/scans/capture Beacon called by the hosted page after a resolve-driven anonymous visit (?_r= linkback present) to record it into the visitor's own scan history. No-op for an authenticated caller (already captured resolver-side) or a scan_token that doesn't resolve to a real ScanEvent. # List recently viewed products Source: https://docs.closient.com/api-reference/account/scan-history/list-recently-viewed-products /openapi/openapi-account.json get /account/api/v1/history/viewed Paginated Recently Viewed history for the current authenticated caller — every hosted product page visit, not just resolver-driven scans. Authenticated-only (no anonymous/session-based Recently Viewed); an unauthenticated caller is 401'd by the app's default auth chain. # List scan history Source: https://docs.closient.com/api-reference/account/scan-history/list-scan-history /openapi/openapi-account.json get /account/api/v1/history/scans Paginated scan history for the current visitor — authenticated by session/API key, or by the anonymous session id when not logged in. Mirrors /account/history exactly. An anonymous visitor with no session id yet, or any visitor with no scans, gets a 200 with an empty page — never an error state. # Check store availability for a product Source: https://docs.closient.com/api-reference/agent/availability/check-store-availability-for-a-product /openapi/openapi-agent.json get /agent/api/v1/products/{gtin}/availability Returns nearby store availability for a known product. Provide the user's location and search radius to find stores carrying this product, sorted by distance (closest first). # Get full product detail by GTIN Source: https://docs.closient.com/api-reference/agent/products/get-full-product-detail-by-gtin /openapi/openapi-agent.json get /agent/api/v1/products/{gtin} Returns comprehensive product information including substance composition, certifications, and claims for a given GTIN. Accepts GTIN-8, GTIN-12, GTIN-13, or GTIN-14 formats. # Resolve product query to nearby store availability Source: https://docs.closient.com/api-reference/agent/resolve/resolve-product-query-to-nearby-store-availability /openapi/openapi-agent.json post /agent/api/v1/resolve Accepts a natural-language product query plus a geographic anchor and optional filtering constraints (certifications, allergens, substances, price, nutrients) and returns ranked product matches with real-time nearby store availability. **Ranking** is a blend of semantic similarity to ``query``, proximity to ``location.latitude``/``location.longitude`` (within ``radius_km``), and constraint compliance. Each result carries a list of ``StoreAvailabilityOut`` rows sorted closest-first. **Constraints** in the payload are merged with the authenticated user's saved preferences (explicit payload wins on conflict); a missing ``constraints`` key means use the user's preferences as-is. **Empty results** return ``200`` with ``results: []`` — the endpoint does not 404 when nothing matches. # Paginated raw scan events for BI integration Source: https://docs.closient.com/api-reference/analytics/exports/paginated-raw-scan-events-for-bi-integration /openapi/openapi-analytics.json get /analytics/api/v1/exports/scan-events Raw scan-event rows for the requested window — the same column set as the Exports tab's CSV download, paginated. Page through with ``?page=2&page_size=100`` (max 100 per page). Caller must be a member of ``organization_id`` (or hold an API key scoped to it) or a 404 is returned. # Period summary for BI integration Source: https://docs.closient.com/api-reference/analytics/exports/period-summary-for-bi-integration /openapi/openapi-analytics.json get /analytics/api/v1/exports/overview The same period-summary payload the Exports tab's PDF report renders, as JSON: resolves / page-visit totals with period-over-period deltas, conversion rate, top GTINs, and country breakdown. Caller must be a member of ``organization_id`` (or hold an API key scoped to it) or a 404 is returned. # Hierarchical traffic time series for a scope Source: https://docs.closient.com/api-reference/analytics/traffic/hierarchical-traffic-time-series-for-a-scope /openapi/openapi-analytics.json get /analytics/api/v1/traffic Returns a stacked time series suitable for an ECharts ``stacked area / bar`` chart, plus a breadcrumb describing the drill-down path. The shape mirrors the Customer Analytics Dashboard contract (C-1045). Scopes form a hierarchy ``organization → brand → product_group (optional) → product``; the response's ``series[*].type`` discriminator tells the frontend whether clicking a stacked segment should drill into a product group, an ungrouped product, or stop. Data comes from the pre-aggregated ``ScanDailySummary`` table — no raw event scans are queried at request time. Today's partial day is excluded; pre-aggregation runs nightly. Caller must be a member of ``organization_id`` or a 404 is returned. ``scope_id`` (when supplied) must belong to that organization or a 404 is returned. # Cancel resolver subscription Source: https://docs.closient.com/api-reference/billing/billing/cancel-resolver-subscription /openapi/openapi-billing.json post /billing/api/v1/billing/resolver/{organization_id}/cancel Cancel the active resolver subscription via the Stripe API. Requires the BILLING role or organization ownership. The local ``resolver_subscription_status`` is set to ``CANCELLED`` immediately for UI consistency; the Stripe-fired ``customer.subscription.deleted`` webhook is the authoritative confirmation. Returns ``400`` when there is no active subscription to cancel. # Create billing portal session Source: https://docs.closient.com/api-reference/billing/billing/create-billing-portal-session /openapi/openapi-billing.json post /billing/api/v1/billing/resolver/{organization_id}/portal Open a Stripe Customer Portal session for self-service subscription management — payment methods, invoice history, and subscription cancellation. Requires the BILLING role or organization ownership. If the organization has no Stripe customer record yet, one is created on the fly. # Create resolver subscription checkout Source: https://docs.closient.com/api-reference/billing/billing/create-resolver-subscription-checkout /openapi/openapi-billing.json post /billing/api/v1/billing/resolver/checkout Create a Stripe Checkout session in subscription mode for the GS1 Digital Link resolver. The caller must hold the BILLING role or be an owner. Returns ``400`` if the organization already has an active resolver subscription — cancel the existing one via the Customer Portal before opening a new Checkout. # Get resolver billing summary Source: https://docs.closient.com/api-reference/billing/billing/get-resolver-billing-summary /openapi/openapi-billing.json get /billing/api/v1/billing/resolver/{organization_id} Get the resolver subscription summary: tier, lifecycle status, current billable usage, Free-tier limits (when applicable), and the estimated next monthly invoice (paid tiers only). Any member of the organization can read this — no BILLING role required. The estimate is computed via the same code path that produces actual Stripe invoices, so it cannot drift from the billed amount. # Stripe webhook Source: https://docs.closient.com/api-reference/billing/billing/stripe-webhook /openapi/openapi-billing.json post /billing/api/v1/billing/webhook Receive an inbound Stripe webhook event. Unauthenticated — request validity is established by verifying the ``Stripe-Signature`` header against the configured signing secret. Returns ``400`` for a missing or invalid signature; handlers themselves are not caught — they raise on internal failures so Stripe retries (at-least-once delivery) and Sentry pages. # Approximate caller location Source: https://docs.closient.com/api-reference/brands/brand-locator-public/approximate-caller-location /openapi/openapi-brands.json get /brands/api/v1/public/geo Returns the caller's approximate location, derived server-side from the edge network's view of the connection (Cloudflare `CF-*` visitor-location headers). Intended as the brand locator's fallback when the browser's `navigator.geolocation` permission is denied or times out, so the widget never has to hand the visitor's IP to a third-party geolocation service. City-level accuracy at best. All fields are `null` when the edge did not supply a location — callers must handle that and fall back to asking the visitor for an address. No IP address is stored or returned. **Public, unauthenticated**, rate limited by IP. The response is `private, no-store`: it is derived from the caller's own connection, so a shared cache in front of it would serve one visitor's city to the next. # Find stores carrying a brand Source: https://docs.closient.com/api-reference/brands/brand-locator-public/find-stores-carrying-a-brand /openapi/openapi-brands.json get /brands/api/v1/public/brands/{brand_id}/locator Returns the brand's identity and the physical stores within `radius` km of (`lat`, `lon`) that carry at least one of the brand's products as an active in-store offer, sorted by ascending great-circle distance. **Public, unauthenticated** — designed to be called from the brand's website via the embeddable widget. CORS is open. Coordinates are WGS 84 / EPSG:4326 (the standard used by browsers' `navigator.geolocation` and Google Maps); PostGIS computes distance on the geography type. # Geocode a free-text address Source: https://docs.closient.com/api-reference/brands/brand-locator-public/geocode-a-free-text-address /openapi/openapi-brands.json get /brands/api/v1/public/geocode Resolves a free-text place description (city, postal code, or street address) to coordinates you can pass to `/brands/{brand_id}/locator` as `lat`/`lon`. The lookup runs server-side against Closient's configured geocoder and is cached, so the visitor's browser never contacts the geocoding provider directly. `lat`/`lon` are `null` when nothing matched — that is a successful `200`, not an error. A `502` means the geocoding provider itself was unreachable (`retryable: true`). **Public, unauthenticated**, rate limited by IP more tightly than the rest of this router: a cache miss here costs an outbound request against Closient's geocoding quota. # List a brand's products (locator catalog) Source: https://docs.closient.com/api-reference/brands/brand-locator-public/list-a-brands-products-locator-catalog /openapi/openapi-brands.json get /brands/api/v1/public/brands/{brand_id}/catalog Returns the brand's products for populating the embeddable widget's product-filter dropdown, plus `has_distribution` — whether the brand is carried in any store at all. **Public, unauthenticated**. The widget calls this first, before asking the visitor for a location, so that a brand with no distribution renders nothing instead of prompting for geolocation it has no use for. Capped at the first 250 products by display name — brands with longer catalogs need the search variant (follow-up). # Track brand locator widget events Source: https://docs.closient.com/api-reference/brands/brand-locator-public/track-brand-locator-widget-events /openapi/openapi-brands.json post /brands/api/v1/public/widget-events Fire-and-forget telemetry for the embeddable brand product locator widget. Designed for `navigator.sendBeacon()` and background `fetch()` calls — no auth, no rate limit, always returns `204 No Content`. The body is structured-logged server-side (event type, brand id, referrer, product id, store id, query) but is not persisted to the database. Unknown event types are silently ignored. # Claim brand for organization Source: https://docs.closient.com/api-reference/brands/brands/claim-brand-for-organization /openapi/openapi-brands.json post /brands/api/v1/brands/{id}/claim Attach an existing unclaimed brand to an organization, going through the same verification-tier computation as the dashboard's claim flow. Requires OWNER or MANAGER role on the organization (never the brand's, which has none yet). **This is permanent** — once a brand has an owning organization, no interface can detach or reassign it. Re-claiming a brand the calling organization already owns is idempotent. Returns 409, not a silent no-op, if the brand is claimed by a different organization or the calling organization already has a brand with the same name. # Create brand Source: https://docs.closient.com/api-reference/brands/brands/create-brand /openapi/openapi-brands.json post /brands/api/v1/brands Create a new brand for an organization. Requires OWNER or MANAGER role. # Delete brand Source: https://docs.closient.com/api-reference/brands/brands/delete-brand /openapi/openapi-brands.json delete /brands/api/v1/brands/{id} Soft-delete a brand (sets is_active=False). Requires OWNER or MANAGER role. # Get brand detail Source: https://docs.closient.com/api-reference/brands/brands/get-brand-detail /openapi/openapi-brands.json get /brands/api/v1/brands/{id} Retrieve detailed brand information by short ID. # List brands Source: https://docs.closient.com/api-reference/brands/brands/list-brands /openapi/openapi-brands.json get /brands/api/v1/brands List all brands with optional filtering by name or organization. # Update brand Source: https://docs.closient.com/api-reference/brands/brands/update-brand /openapi/openapi-brands.json patch /brands/api/v1/brands/{id} Update brand profile fields. Requires OWNER or MANAGER role. # Upload brand logo Source: https://docs.closient.com/api-reference/brands/brands/upload-brand-logo /openapi/openapi-brands.json post /brands/api/v1/brands/{id}/logo Upload or replace a brand logo image. Requires OWNER or MANAGER role. # TRMNL device log Source: https://docs.closient.com/api-reference/byos/byos-trmnl/trmnl-device-log /openapi/openapi-byos.json post /byos/api/log/ Accept a diagnostic log payload from a TRMNL device. Firmware posts these on error or warning conditions (low battery, image decode failures, etc.) — Closient persists the payload verbatim because TRMNL's schema is informally specified and may evolve. The staff admin surface renders the JSON for operator triage. **Auth:** ``Access-Token`` header. * ``200`` — payload accepted. * ``401`` — missing, unknown, or disabled ``Access-Token``. # TRMNL device setup Source: https://docs.closient.com/api-reference/byos/byos-trmnl/trmnl-device-setup /openapi/openapi-byos.json get /byos/api/setup/ Called by TRMNL firmware on first boot. The device presents its MAC in the ``ID`` header; the server responds with an ``api_key`` (used as ``Access-Token`` on every subsequent call), a 6-char ``friendly_id``, the URL of the device's first image, and a suggested filename for local caching. If the MAC has never been seen, an unclaimed :class:`BYOSDevice` row is created — the device starts in the ``unclaimed`` state and renders the BYOS fallback image until an operator links it to an organization. **Auth:** unauthenticated. The TRMNL firmware doesn't have an ``Access-Token`` yet at this call. * ``200`` — setup succeeded; device may begin polling ``/api/display``. * ``400`` — the ``ID`` header is missing or not a 12-char hex MAC. # TRMNL display poll Source: https://docs.closient.com/api-reference/byos/byos-trmnl/trmnl-display-poll /openapi/openapi-byos.json get /byos/api/display Called by TRMNL firmware on every refresh cycle to decide what to paint. The server returns an ``image_url`` pointing to the device's next PNG plus a ``refresh_rate`` telling firmware when to poll again. Phase 1 always returns the current ``image_url`` — image generation is synchronous and per-request. A future phase will switch to S3-backed pre-rendered images so many devices can share a single render. **Auth:** ``Access-Token`` header (the opaque per-device token issued by ``/api/setup/``). * ``200`` — image ready. * ``401`` — missing, unknown, or disabled ``Access-Token``. # Assign certification to product Source: https://docs.closient.com/api-reference/certifications/assignments/assign-certification-to-product /openapi/openapi-certifications.json post /certifications/api/v1/products/{gtin}/certifications Create a new certification assignment for a product your organization owns. Returns ``201`` with the created assignment on success; ``403`` when the API key's role may not contribute (a read-only key); ``404`` if the GTIN does not match a product **your organization owns** or the ``certification_id`` does not exist; ``409`` if the certification is already assigned to this product (the (product, certification) pair is unique). A product owned by another organization, and one owned by nobody, both return ``404``: the status code never reveals whether a GTIN exists in the catalog. Claim an unowned GTIN through ``POST /products/{gtin}/claim`` first. # Assign claim to product Source: https://docs.closient.com/api-reference/certifications/assignments/assign-claim-to-product /openapi/openapi-certifications.json post /certifications/api/v1/products/{gtin}/claims Create a new claim assignment for a product your organization owns. Returns ``201`` with the created assignment on success; ``403`` when the API key's role may not contribute (a read-only key); ``404`` if the GTIN does not match a product **your organization owns** or the ``claim_id`` does not exist; ``409`` if the claim is already assigned to this product (the (product, claim) pair is unique). This is a product *content* claim (Non-GMO, Vegan). Claiming *ownership* of a GTIN is a different endpoint — ``POST /products/{gtin}/claim`` — and is how an unowned product becomes writable here. # Get a product certification Source: https://docs.closient.com/api-reference/certifications/assignments/get-a-product-certification /openapi/openapi-certifications.json get /certifications/api/v1/products/{gtin}/certifications/{certification_id} Retrieve one certification assigned to a product, with the same fields as the list endpoint. ``certification_id`` is the certification's ``id`` as returned by the list. Returns ``404`` when no product matches the GTIN or the certification is not assigned to it. Like the list, this is a public read. # Get a product claim Source: https://docs.closient.com/api-reference/certifications/assignments/get-a-product-claim /openapi/openapi-certifications.json get /certifications/api/v1/products/{gtin}/claims/{claim_id} Retrieve one claim assigned to a product, with the same fields as the list endpoint. ``claim_id`` is the claim's ``id`` as returned by the list. Returns ``404`` when no product matches the GTIN or the claim is not assigned to it. Like the list, this is a public read. # List product certifications Source: https://docs.closient.com/api-reference/certifications/assignments/list-product-certifications /openapi/openapi-certifications.json get /certifications/api/v1/products/{gtin}/certifications Retrieve every certification assigned to a product, including verification status, issuing body, and validity dates. Filter by verification status or active validity-window membership. The ``is_active`` boolean returned per row is computed at request time from ``valid_from`` / ``expiration_date``; the ``is_active`` query parameter pre-filters rows server-side using the same rule. # List product claims Source: https://docs.closient.com/api-reference/certifications/assignments/list-product-claims /openapi/openapi-certifications.json get /certifications/api/v1/products/{gtin}/claims Retrieve every claim (e.g. ``Non-GMO``, ``Vegan``) assigned to a product, with source (self-declared / third-party-verified) and validity dates. Returns ``404`` when no product matches the GTIN. # Remove certification from product Source: https://docs.closient.com/api-reference/certifications/assignments/remove-certification-from-product /openapi/openapi-certifications.json delete /certifications/api/v1/products/{gtin}/certifications/{certification_id} Delete a certification assignment from a product your organization owns. Returns ``204`` on success, ``403`` when the API key's role may not contribute (a read-only key), or ``404`` when the GTIN does not match a product your organization owns or the assignment does not exist. Idempotent semantics are *not* provided: a second delete call returns ``404``, not ``204``. # Remove claim from product Source: https://docs.closient.com/api-reference/certifications/assignments/remove-claim-from-product /openapi/openapi-certifications.json delete /certifications/api/v1/products/{gtin}/claims/{claim_id} Delete a claim assignment from a product your organization owns. Returns ``204`` on success, ``403`` when the API key's role may not contribute (a read-only key), or ``404`` when the GTIN does not match a product your organization owns or the assignment does not exist. # Update certification assignment Source: https://docs.closient.com/api-reference/certifications/assignments/update-certification-assignment /openapi/openapi-certifications.json patch /certifications/api/v1/products/{gtin}/certifications/{certification_id} Partially update an existing certification assignment on a product your organization owns. Only fields present in the request body are applied; omitted fields are left unchanged. ``metadata`` follows Stripe-style merge semantics. Returns ``403`` when the API key's role may not contribute (a read-only key), and ``404`` when the GTIN does not match a product your organization owns or the certification does not match an existing assignment on it. # Update claim assignment Source: https://docs.closient.com/api-reference/certifications/assignments/update-claim-assignment /openapi/openapi-certifications.json patch /certifications/api/v1/products/{gtin}/claims/{claim_id} Partially update a claim assignment on a product your organization owns. Only fields present in the request body are applied; omitted fields are left unchanged. ``metadata`` follows Stripe-style merge semantics. Returns ``403`` when the API key's role may not contribute (a read-only key), and ``404`` when the GTIN does not match a product your organization owns or the claim is not assigned to it. # Get certification body Source: https://docs.closient.com/api-reference/certifications/certification-bodies/get-certification-body /openapi/openapi-certifications.json get /certifications/api/v1/certification-bodies/{id} Retrieve a single certification body by its ``id``. Returns ``404`` when no body matches the identifier. # List certification bodies Source: https://docs.closient.com/api-reference/certifications/certification-bodies/list-certification-bodies /openapi/openapi-certifications.json get /certifications/api/v1/certification-bodies Retrieve every certification-issuing body (e.g. USDA, Fair Trade USA, Oregon Tilth, MSC, FSC). Filterable by name; sortable by ``name``. Paginated with the standard ``{data, pagination}`` envelope; default ``page_size`` is 25, max 100. # Get certification Source: https://docs.closient.com/api-reference/certifications/certifications/get-certification /openapi/openapi-certifications.json get /certifications/api/v1/certifications/{id} Retrieve a single certification definition by its ``id``. Returns ``404`` when no certification matches the identifier. # List certifications Source: https://docs.closient.com/api-reference/certifications/certifications/list-certifications /openapi/openapi-certifications.json get /certifications/api/v1/certifications Retrieve certification *definitions* — the canonical records (USDA Organic, Fair Trade, MSC, ...) that products are then assigned to. Filterable by label, code, and certification body. Paginated. # Get claim Source: https://docs.closient.com/api-reference/certifications/claims/get-claim /openapi/openapi-certifications.json get /certifications/api/v1/claims/{id} Retrieve a single claim definition by its ``id``. Returns ``404`` when no claim matches the identifier. # List claims Source: https://docs.closient.com/api-reference/certifications/claims/list-claims /openapi/openapi-certifications.json get /certifications/api/v1/claims Retrieve claim *definitions* — the canonical records (Non-GMO, Vegan, Gluten-Free, ...) that products are then assigned to. Filterable by label, code, and category. Paginated. # Get a brand-issued recall Source: https://docs.closient.com/api-reference/compliance/compliance/get-a-brand-issued-recall /openapi/openapi-compliance.json get /compliance/api/v1/recalls/{recall_id} Retrieve a single brand-issued recall you own, including its scope list. # Issue a brand recall Source: https://docs.closient.com/api-reference/compliance/compliance/issue-a-brand-recall /openapi/openapi-compliance.json post /compliance/api/v1/recalls Issue a recall for one of your own products at any granularity: whole product (omit ``scopes`` or send ``[]``), a lot set (``LOT_LIST``), a serial set (``SERIAL_SET``), a production site (``PRODUCTION_SITE``, the GTIN + GLN combination), a printed-date window (``DATE_WINDOW``), or any mix — multiple scopes OR-combine, so a unit is affected if it matches any of them. Thin over the same ``issue_recall`` service the dashboard uses: it creates a BRAND_ISSUED recall, writes the scopes, links the brand-UI lot/serial pointers, and fires the consumer notification fan-out. Requires the ``contribute`` role on the product's organization. Returns ``422`` if a value-set scope exceeds the configured cap or a date window is malformed. # List product recalls Source: https://docs.closient.com/api-reference/compliance/compliance/list-product-recalls /openapi/openapi-compliance.json get /compliance/api/v1/products/{gtin}/recalls Retrieve every **open** recall affecting a single product, sourced from FDA, USDA FSIS, CPSC, and Health Canada and matched to the product by GTIN/UPC at ingestion time. 'Open' means ``status`` is ``ONGOING`` or ``UNKNOWN`` — completed and terminated recalls are excluded from this endpoint by design (use the bulk recall endpoints if you need those). Filters narrow the open set further; ``ordering`` is opt-in (default order is the model's ``-recall_date, -created``). Results are paginated with the standard ``{data, pagination}`` envelope; max ``page_size`` is 100. # List recalls affecting a specific batch/lot Source: https://docs.closient.com/api-reference/compliance/compliance/list-recalls-affecting-a-specific-batchlot /openapi/openapi-compliance.json get /compliance/api/v1/products/{gtin}/batches/{batch}/recalls Retrieve every **open** recall affecting a specific ``(GTIN, batch)`` pair, sourced from FDA, USDA FSIS, CPSC, and Health Canada. Where ``GET /products/{gtin}/recalls`` returns *every* open recall for the GTIN, this endpoint filters to recalls whose ``lot_codes`` field matches the supplied ``batch`` value — by exact match, by inclusion in a numeric range (``"lots 20261001 through 20261015"``), or by wildcard prefix (``"LOT-2026-*"``). Recalls that do not restrict to specific lots are always included (regulator did not narrow the scope, so every batch is considered affected). Same filter / ordering / pagination contract as the GTIN-only endpoint. Designed for consumer scans of GS1 Digital Link URIs encoding batch identity in AI 10: ``/01/{gtin}/10/{batch}`` — call this endpoint with the scanned GTIN and batch to answer 'is *this lot* under recall?' rather than 'are any batches of this GTIN recalled?'. # List your brand-issued recalls Source: https://docs.closient.com/api-reference/compliance/compliance/list-your-brand-issued-recalls /openapi/openapi-compliance.json get /compliance/api/v1/recalls List recalls your organizations have issued (``source_system = BRAND_ISSUED``), including each recall's scope list. Does not include government-ingested recalls — use ``GET /products/{gtin}/recalls`` for those. Paginated with the standard envelope. # Withdraw a brand recall Source: https://docs.closient.com/api-reference/compliance/compliance/withdraw-a-brand-recall /openapi/openapi-compliance.json post /compliance/api/v1/recalls/{recall_id}/withdraw Withdraw a brand-issued recall you own. Sets its status to ``TERMINATED``, which deactivates the resolver interstitial. Already-sent consumer notifications are not recalled — a safety alert cannot be un-sent. Idempotent-friendly: withdrawing an already-terminated recall is a no-op that returns the recall. # Check product data readiness Source: https://docs.closient.com/api-reference/content/content/check-product-data-readiness /openapi/openapi-content.json post /content/api/v1/learn/readiness-check Evaluate product data completeness for a GTIN in the Closient product repository. The service normalizes the input to GTIN-14, validates the GS1 check digit, and (if found) computes a weighted completeness score across seven product attributes (product name, brand, description, image, manufacturer, country of origin, net content). Returns the score, per-field breakdown, and actionable recommendations. If an ``email`` is supplied with a valid GTIN, a ``ReadinessCheckLead`` is recorded for follow-up. **Authentication:** none — anonymous access is allowed. **Rate limit:** 10 requests per minute per source IP via the Ninja throttle, plus a 30-per-hour cache-backed limit per IP. Exceeding either yields ``429 Too Many Requests`` with an RFC 9457 Problem Details body and a ``retry_after_seconds`` hint. Validation failures (malformed GTIN, bad check digit) return HTTP 200 with ``valid: false`` and a populated ``error``. Only infrastructural problems use error envelopes. # Record a 'Copy for your AI agent' button click Source: https://docs.closient.com/api-reference/dashboard/agent-handoff-capsules/record-a-copy-for-your-ai-agent-button-click /openapi/openapi-dashboard.json post /dashboard/api/v1/capsules/{capsule_id}/copied Fire-and-forget telemetry for the capsule copy-to-clipboard action. Requires an authenticated caller (any organization membership — this endpoint records no organization-scoped data of its own), but is not scoped to a specific organization. Silently no-ops, still returning 204, when the visitor has not granted analytics consent — clipboard copy itself never depends on this call succeeding. # Brand retail footprint — where are my products being sold? Source: https://docs.closient.com/api-reference/dashboard/brand-analytics/brand-retail-footprint-—-where-are-my-products-being-sold? /openapi/openapi-dashboard.json get /dashboard/api/v1/brand-analytics/retail-footprint Returns one row per physical store × product (with stock signals and freshness), or a city / region roll-up depending on ``group_by``. Drives the ``brand-retail-footprint`` Agent Skill. Scoped to the caller's organization: only offers attached to products whose brand is owned by ``organization_id`` (or the caller's single org, when omitted) are returned. Region is ISO 3166 in either bare-country (``US``) or country-subdivision (``US-TX``) form. ``stale_inventory`` flips true when ``last_inventory_sync`` is older than ``BRAND_ANALYTICS_STALE_INVENTORY_HOURS`` (default 48h). # Brand scan summary — which of my products are being scanned the most? Source: https://docs.closient.com/api-reference/dashboard/brand-analytics/brand-scan-summary-—-which-of-my-products-are-being-scanned-the-most? /openapi/openapi-dashboard.json get /dashboard/api/v1/brand-analytics/scans Returns per-product scan totals plus country / device breakdowns and a daily volume timeline for products owned by ``organization_id``. Drives the ``brand-scan-summary`` Agent Skill. Scoped to the caller's organization: only ``ScanDailySummary`` rows for products whose brand is owned by the resolved org are counted. ``brand_id`` narrows further to a single brand's product slate. The window defaults to the last 30 days when neither ``since`` nor ``until`` is provided; ``until`` is inclusive (scans on the boundary date are counted). Bot vs billable: ``total_scans = billable_scans + bot_scans`` is the rough invariant, though edge cases (manually-corrected rows) can move them independently — the three fields are reported as stored. City-level geo is not yet exposed via this endpoint; use ``brand-search-demand`` for metro-level intent as a proxy. Retention: this endpoint reads from the indefinitely-retained ``ScanDailySummary`` aggregate, so historical windows remain queryable even after raw events age out at 90 days. See the [Analytics Retention & Privacy](https://docs.closient.com/concepts/analytics-retention) page for the full policy. # Brand search demand — where are people searching for my products? Source: https://docs.closient.com/api-reference/dashboard/brand-analytics/brand-search-demand-—-where-are-people-searching-for-my-products? /openapi/openapi-dashboard.json get /dashboard/api/v1/brand-analytics/search-demand Returns the top geographic areas (metro / city / county) where search queries surfaced products owned by ``organization_id``. Drives the ``brand-search-demand`` Agent Skill. Scoped to the caller's organization: only impressions whose ``organization`` matches the resolved org (or the caller's single org, when ``organization_id`` is omitted) are counted. Buckets are lat/lon cells roughly ``radius_km`` on a side; the ``grouping`` parameter picks a default radius (``metro``=25km, ``city``=5km, ``county``=50km) which ``radius_km`` may override. ``window`` defaults to the last 30 days when neither ``since`` nor ``until`` is provided. # Get product completeness score Source: https://docs.closient.com/api-reference/dashboard/completeness/get-product-completeness-score /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/completeness Returns the per-field completeness breakdown that powers the brand dashboard's data-quality widget. The same scoring formula drives the ``avg_completeness`` and ``complete_products`` counters on the stats endpoint, so callers can mix this and ``GET /stats/{organization_id}`` without normalizing. Caller must be a member of ``organization_id``; the product must be owned by that organization or a 404 is returned. # Poll an organization data export Source: https://docs.closient.com/api-reference/dashboard/data-export/poll-an-organization-data-export /openapi/openapi-dashboard.json get /dashboard/api/v1/data-export/{organization_id}/{job_id} Return the status of a previously-requested export. Once `status` is `completed`, the response carries a time-limited signed `download_url`. # Request an organization data export Source: https://docs.closient.com/api-reference/dashboard/data-export/request-an-organization-data-export /openapi/openapi-dashboard.json post /dashboard/api/v1/data-export/{organization_id} Start a best-effort export of the organization's data as a downloadable archive (folder tree of YAML files plus binary assets). Runs as a background job; poll the returned `job_id` for the download link. This is a convenience export, not a backup. # Delete product document Source: https://docs.closient.com/api-reference/dashboard/documents/delete-product-document /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/documents/{document_id} Permanently delete a document from a product. Returns 204 on success and 404 if the document does not belong to the targeted product. Requires the ``organization.contribute_organization`` permission on the membership. # Get product document Source: https://docs.closient.com/api-reference/dashboard/documents/get-product-document /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/documents/{document_id} Fetch a single document by ID. 404 if it does not belong to the targeted product. Caller must be a member of ``organization_id``. # List product documents Source: https://docs.closient.com/api-reference/dashboard/documents/list-product-documents /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/documents Paginated list of PDF documents attached to a product, most-recent first. Caller must be a member of ``organization_id`` and the product must be owned by that organization. # Suggest document labels Source: https://docs.closient.com/api-reference/dashboard/documents/suggest-document-labels /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/document-labels Labels the organization has already used on its own documents, for the document form's label typeahead. Case-insensitive substring match on ``q``; prefix matches first; at most 20. Never includes another organization's labels. Caller must be a member of ``organization_id``. # Update product document metadata Source: https://docs.closient.com/api-reference/dashboard/documents/update-product-document-metadata /openapi/openapi-dashboard.json patch /dashboard/api/v1/products/{organization_id}/{gtin}/documents/{document_id} Update a document's title / description. Only supplied fields change. To replace the file itself, upload a new document instead. Requires the ``organization.contribute_organization`` permission on the membership. # Upload product document Source: https://docs.closient.com/api-reference/dashboard/documents/upload-product-document /openapi/openapi-dashboard.json post /dashboard/api/v1/products/{organization_id}/{gtin}/documents Upload a single PDF document and attach it to the product. Non-PDF uploads are rejected with 422. PDF metadata (page count, thumbnail) is extracted asynchronously after upload, so those fields may be null in the immediate response. Requires the ``organization.contribute_organization`` permission on the membership. # Add a GS1 Company Prefix Source: https://docs.closient.com/api-reference/dashboard/gs1-company-prefixes/add-a-gs1-company-prefix /openapi/openapi-dashboard.json post /dashboard/api/v1/company-prefixes/{organization_id} Add a GS1 Company Prefix (licensed or closed-loop) to the caller's organization. Idempotent: adding a prefix already on file for this org returns the existing row. Owner/manager only (`organization.change_organization`). # Get a GS1 Company Prefix Source: https://docs.closient.com/api-reference/dashboard/gs1-company-prefixes/get-a-gs1-company-prefix /openapi/openapi-dashboard.json get /dashboard/api/v1/company-prefixes/{organization_id}/{company_prefix_id} Return one GS1 Company Prefix the caller's organization holds, with its verification status and conformance state. Caller must be a member of `organization_id`, and the prefix must be the organization's own; otherwise a 404 is returned. # List the organization's GS1 Company Prefixes Source: https://docs.closient.com/api-reference/dashboard/gs1-company-prefixes/list-the-organizations-gs1-company-prefixes /openapi/openapi-dashboard.json get /dashboard/api/v1/company-prefixes/{organization_id} List every GS1 Company Prefix (licensed or closed-loop) the caller's organization holds, with its current verification status and conformance state. Caller must be a member of `organization_id` or a 404 is returned. # Mint a GIAI from one of the organization's prefixes Source: https://docs.closient.com/api-reference/dashboard/gs1-company-prefixes/mint-a-giai-from-one-of-the-organizations-prefixes /openapi/openapi-dashboard.json post /dashboard/api/v1/company-prefixes/{organization_id}/{company_prefix_id}/giai Self-service GIAI (AI 8004) minting: builds an owner-assigned individual-asset identifier from `company_prefix_id` (which must belong to the caller's organization) + the supplied asset reference, and creates or enriches the backing asset record. Idempotent on the resulting GIAI. Any org member with contribute access may mint (`organization.contribute_organization`). # Recheck a prefix's GS1 Verified / licence status Source: https://docs.closient.com/api-reference/dashboard/gs1-company-prefixes/recheck-a-prefixs-gs1-verified-licence-status /openapi/openapi-dashboard.json post /dashboard/api/v1/company-prefixes/{organization_id}/{company_prefix_id}/verify Run an on-demand check of `verification_status` against Proof-of-Brand licence-claim data (never a manual flag) and persist the result. When no verified claim exists yet for this prefix, the response's `note` documents the fallback and how to resolve it. The same check also runs as a daily background sweep for every not-yet-verified licensed prefix. Owner/manager only (`organization.change_organization`). # Remove a GS1 Company Prefix Source: https://docs.closient.com/api-reference/dashboard/gs1-company-prefixes/remove-a-gs1-company-prefix /openapi/openapi-dashboard.json delete /dashboard/api/v1/company-prefixes/{organization_id}/{company_prefix_id} Remove one of the caller's organization's GS1 Company Prefixes. Assets/locations already minted from it are not deleted — they become unanchored (non-conformant). Owner/manager only (`organization.change_organization`). # Delete product image Source: https://docs.closient.com/api-reference/dashboard/images/delete-product-image /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/images/{image_id} Permanently delete an image from a product. Returns 204 on success and 404 if the image does not belong to the targeted product. Requires the ``organization.contribute_organization`` permission. # Get product image Source: https://docs.closient.com/api-reference/dashboard/images/get-product-image /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/images/{image_id} Fetch a single image by ID. 404 if it does not belong to the targeted product. Caller must be a member of ``organization_id``. # List product images Source: https://docs.closient.com/api-reference/dashboard/images/list-product-images /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/images Paginated list of images attached to a product, ordered with the primary image first, then by ascending ``priority``, then most-recent upload. Caller must be a member of ``organization_id`` and the product must be owned by that organization. # Update product image Source: https://docs.closient.com/api-reference/dashboard/images/update-product-image /openapi/openapi-dashboard.json patch /dashboard/api/v1/products/{organization_id}/{gtin}/images/{image_id} Partially update an image's caption (title), primary flag, or display order (priority) — only supplied fields change. Setting is_primary=true automatically demotes the previous primary image, same as upload. Requires the ``organization.contribute_organization`` permission. # Upload product image Source: https://docs.closient.com/api-reference/dashboard/images/upload-product-image /openapi/openapi-dashboard.json post /dashboard/api/v1/products/{organization_id}/{gtin}/images Upload a single image and attach it to the product. Accepts JPEG, PNG, GIF, WebP, SVG, or BMP. When ``is_primary`` is true the previously primary image (if any) is automatically demoted, so at most one image per product carries the flag. Requires the ``organization.contribute_organization`` permission on the membership. Idempotent (C-6340): when the product already has an image with the same bytes (SHA-256), or at the same non-empty ``source_url``, that image is returned with **200** and nothing is created or changed on it, except that an empty ``source_url`` on it is filled in. A new image returns **201**. A client that lost a response can therefore safely send the upload again. # Add a product nutrient row Source: https://docs.closient.com/api-reference/dashboard/nutrition/add-a-product-nutrient-row /openapi/openapi-dashboard.json post /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition/nutrients Add a single nutrient row to the product's nutrition panel. The header must exist first (422 otherwise). Each ``nutrient_type`` may appear at most once per label (409 on a duplicate). Requires the ``organization.contribute_organization`` permission on the membership. # Create or replace product nutrition header Source: https://docs.closient.com/api-reference/dashboard/nutrition/create-or-replace-product-nutrition-header /openapi/openapi-dashboard.json put /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition Create the nutrition-facts header for a product, or update it in place when one already exists (the header is one-per-product). Nutrient rows are managed separately via the ``/nutrients`` endpoints. Requires the ``organization.contribute_organization`` permission on the membership. # Delete a product nutrient row Source: https://docs.closient.com/api-reference/dashboard/nutrition/delete-a-product-nutrient-row /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition/nutrients/{nutrient_id} Permanently delete a nutrient row from the product's panel. Requires the ``organization.contribute_organization`` permission on the membership. # Delete product nutrition facts Source: https://docs.closient.com/api-reference/dashboard/nutrition/delete-product-nutrition-facts /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition Permanently delete the nutrition-facts panel and all of its nutrient rows. 404 if the product has no panel recorded. Requires the ``organization.contribute_organization`` permission on the membership. # Get product nutrition facts Source: https://docs.closient.com/api-reference/dashboard/nutrition/get-product-nutrition-facts /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition Return the structured nutrition-facts panel (serving size, servings per container, label format, and per-nutrient rows with their ids). Returns 404 when no panel has been recorded for the product. # Update a product nutrient row Source: https://docs.closient.com/api-reference/dashboard/nutrition/update-a-product-nutrient-row /openapi/openapi-dashboard.json patch /dashboard/api/v1/products/{organization_id}/{gtin}/nutrition/nutrients/{nutrient_id} Partially update a nutrient row — only supplied fields change. Changing ``nutrient_type`` to one already on the label returns 409. Requires the ``organization.contribute_organization`` permission on the membership. # Create or replace product registration link Source: https://docs.closient.com/api-reference/dashboard/register-product/create-or-replace-product-registration-link /openapi/openapi-dashboard.json put /dashboard/api/v1/products/{organization_id}/{gtin}/register-product Create the product's registration link, or replace it in place when one already exists (a product has at most one). The whole link is replaced: an omitted ``label`` or ``description`` becomes blank. The URL must be https and may carry the substitution tokens the hosted page fills from the scan. Requires the ``organization.contribute_organization`` permission on the membership. # Delete product registration link Source: https://docs.closient.com/api-reference/dashboard/register-product/delete-product-registration-link /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/register-product Permanently delete the product's registration link. 404 if none is recorded. Requires the ``organization.contribute_organization`` permission on the membership. # Get product registration link Source: https://docs.closient.com/api-reference/dashboard/register-product/get-product-registration-link /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/register-product Return the registration link shown in the hosted page's REGISTER_PRODUCT section. Returns 404 when no registration link has been recorded for the product. # Get dashboard statistics Source: https://docs.closient.com/api-reference/dashboard/stats/get-dashboard-statistics /openapi/openapi-dashboard.json get /dashboard/api/v1/stats/{organization_id} Returns the aggregated counters rendered on the brand dashboard home (total products, completeness average, active/broken offers, expiring certifications, products missing images). Counts are computed live against the requesting organization — there is no caching layer. Caller must be a member of ``organization_id`` or a 404 is returned. # Add product video Source: https://docs.closient.com/api-reference/dashboard/videos/add-product-video /openapi/openapi-dashboard.json post /dashboard/api/v1/products/{organization_id}/{gtin}/videos Attach a YouTube video link to the product. The URL must be one a video id can be parsed from (watch / youtu.be / embed); other URLs are rejected with 422. Requires the ``organization.contribute_organization`` permission on the membership. # Delete product video Source: https://docs.closient.com/api-reference/dashboard/videos/delete-product-video /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/videos/{video_id} Permanently delete a video from a product. Returns 204 on success and 404 if the video does not belong to the targeted product. Requires the ``organization.contribute_organization`` permission on the membership. # Get product video Source: https://docs.closient.com/api-reference/dashboard/videos/get-product-video /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/videos/{video_id} Fetch a single video by ID. 404 if it does not belong to the targeted product. Caller must be a member of ``organization_id``. # List product videos Source: https://docs.closient.com/api-reference/dashboard/videos/list-product-videos /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/videos Paginated list of YouTube videos attached to a product, ordered by ascending ``priority`` then most-recent. Caller must be a member of ``organization_id`` and the product must be owned by that organization. # Update product video Source: https://docs.closient.com/api-reference/dashboard/videos/update-product-video /openapi/openapi-dashboard.json patch /dashboard/api/v1/products/{organization_id}/{gtin}/videos/{video_id} Partially update a video — only supplied fields change. A new ``youtube_url`` must be parseable to a video id (422 otherwise). Requires the ``organization.contribute_organization`` permission on the membership. # Create or replace product warranty link Source: https://docs.closient.com/api-reference/dashboard/warranty-link/create-or-replace-product-warranty-link /openapi/openapi-dashboard.json put /dashboard/api/v1/products/{organization_id}/{gtin}/warranty-link Create the product's warranty link, or replace its URL in place when one already exists (a product has at most one). The URL must be an http(s) URL. Requires the ``organization.contribute_organization`` permission on the membership. # Delete product warranty link Source: https://docs.closient.com/api-reference/dashboard/warranty-link/delete-product-warranty-link /openapi/openapi-dashboard.json delete /dashboard/api/v1/products/{organization_id}/{gtin}/warranty-link Permanently delete the product's warranty link. 404 if none is recorded. Requires the ``organization.contribute_organization`` permission on the membership. # Get product warranty link Source: https://docs.closient.com/api-reference/dashboard/warranty-link/get-product-warranty-link /openapi/openapi-dashboard.json get /dashboard/api/v1/products/{organization_id}/{gtin}/warranty-link Return the warranty link shown in the hosted page's WARRANTY section. Returns 404 when no warranty link has been recorded for the product. # Capability declaration Source: https://docs.closient.com/api-reference/epcis/epcis-20/capability-declaration /openapi/openapi-epcis.json get /epcis/api/2.0/capability Closient extension, not a GS1-defined operation (the standard's own service-document ``GET /`` is deliberately not served — see ``OPTIONS /``'s docstring). Machine-readable profile of what this EPCIS 2.0 implementation actually does: roles claimed, binding, serialization, the resources served, the GS1-* header set, known non-conformances, and industry-profile positions (C-5407). Generated from the same capability registry (C-5427) and conformance claim (C-5428) every other surface renders from — never a second hand-maintained copy. # Capture EPCIS events Source: https://docs.closient.com/api-reference/epcis/epcis-20/capture-epcis-events /openapi/openapi-epcis.json post /epcis/api/2.0/capture Submit an EPCIS 2.0 document for capture. Returns ``202 Accepted`` with a ``Location`` header naming a capture job, per GS1 EPCIS 2.0 — poll ``GET /capture/{captureID}`` until ``running`` is false, then read ``success`` and ``errors``. **Document-level faults return 400 and create no job**: a missing or empty ``epcisBody.eventList``, or a body that is not an EPCISDocument. **Event-level faults are reported on the job, not as an HTTP error** — including duplicates, which are recorded as ``epcisException:DuplicateEventException`` entries carrying ``sequenceInEPCISDoc`` rather than returning ``409``. A replayed document is a well-formed document. Duplicate detection uses a Closient-internal content fingerprint, scoped to the capturing organization. It is **not** the GS1 EPCIS event Hash-ID (``ni:///sha-256;...?ver=CBV2.0``) and will not match one computed by another implementation; it exists only to make a replayed capture a no-op for the tenant that sent it. The standard Hash-ID is stored for events captured from now on, except those whose ``@context`` or content it cannot be computed under; events captured earlier have none until a backfill runs. Duplicate detection does not use it. ``GS1-Capture-Error-Behaviour`` selects ``rollback`` (default — one invalid event discards the whole document) or ``proceed`` (valid events persist, the rest are reported). Documents of at most 100 events are processed inline and the returned job is already finished; larger ones are processed in the background. Either way the job is pollable, so the client contract does not depend on which path ran. Caller must hold an OWNER or MANAGER membership on the target organization. # Discover /capture capabilities Source: https://docs.closient.com/api-reference/epcis/epcis-20/discover-capture-capabilities /openapi/openapi-epcis.json options /epcis/api/2.0/capture ``OPTIONS /capture`` — GS1 capability discovery (C-5427), declared in the spec (C-5413). # Discover /capture/{captureID} capabilities Source: https://docs.closient.com/api-reference/epcis/epcis-20/discover-capture-capabilities-1 /openapi/openapi-epcis.json options /epcis/api/2.0/capture/{capture_id} ``OPTIONS /capture/{captureID}`` — GS1 capability discovery (C-5427). # Discover /events capabilities Source: https://docs.closient.com/api-reference/epcis/epcis-20/discover-events-capabilities /openapi/openapi-epcis.json options /epcis/api/2.0/events ``OPTIONS /events`` — GS1 capability discovery (C-5427). # Discover /events/{eventID} capabilities Source: https://docs.closient.com/api-reference/epcis/epcis-20/discover-events-capabilities-1 /openapi/openapi-epcis.json options /epcis/api/2.0/events/{event_id} ``OPTIONS /events/{eventID}`` — GS1 capability discovery (C-5427). # Discover /eventTypes capabilities Source: https://docs.closient.com/api-reference/epcis/epcis-20/discover-eventtypes-capabilities /openapi/openapi-epcis.json options /epcis/api/2.0/eventTypes ``OPTIONS /eventTypes`` — GS1 capability discovery (C-5427). Public, like its GET. # Discover service-root capabilities Source: https://docs.closient.com/api-reference/epcis/epcis-20/discover-service-root-capabilities /openapi/openapi-epcis.json options /epcis/api/2.0/ ``OPTIONS /`` — GS1 EPCIS 2.0 §discovery: server settings, versions, extensions. The standard also defines ``GET /`` (a ``TopLevelResourceCollection`` listing every top-level resource) at this same path. We deliberately do not serve it — see ``EPCIS_RESOURCES``'s docstring on ``"/"`` and ``design_decisions.json``'s ``root-get-not-served`` entry for why a role-scoped implementation cannot answer that schema honestly. ``OPTIONS`` carries no such trap: its response is headers only, and every value it reports here is already true of every other response we send. # Enqueue an FSMA 204 traceability export for one lot Source: https://docs.closient.com/api-reference/epcis/epcis-20/enqueue-an-fsma-204-traceability-export-for-one-lot /openapi/openapi-epcis.json get /epcis/api/2.0/traceability/lots/{lot_id}/fda-export FSMA 204 § 1.1455(c) requires records be furnished to FDA within 24 hours of request. This endpoint enqueues an async export of all CriticalTrackingEvents for the given lot — ``forward`` (downstream), ``backward`` (upstream), or ``both`` — and returns a TraceabilityExportJob with a ``short_id``. When the job completes a short-lived signed S3 URL is delivered (poll with the same endpoint or check the compliance dashboard at ``/compliance/``). The full FDA Sortable Spreadsheet template (per-CTE sheets) lands with C-2472; this endpoint emits a minimal single-sheet KDE workbook in the meantime. Returns ``202 Accepted`` for the standard async path and ``200 OK`` when the job completed synchronously (e.g. eager-mode test runs). Caller must hold OWNER or MANAGER on the organization that owns the lot's brand; other orgs receive ``404`` (no leak of lot existence). # Fraud-blocked returns report Source: https://docs.closient.com/api-reference/epcis/epcis-20/fraud-blocked-returns-report /openapi/openapi-epcis.json get /epcis/api/2.0/verification/report Counts of returns-verification decisions for the authenticated organization over a selectable period, with the declined states surfaced as prevented return-fraud attempts and a dollar estimate where the units' sale events carried a price. **Read the two counts as different questions.** ``attempts`` is how many times the return desk asked; ``distinct_units`` is how many physical units were involved. The dollar estimate follows units, never attempts — a unit scanned three times is worth its price once. **Declined means exactly ``already_returned``, ``never_sold``, ``unknown_serial``.** ``recalled_lot`` is reported in ``by_decision`` like every other state but is deliberately not counted as prevented fraud: a recall block is product-safety handling, and a customer returning a unit that was later recalled is not attempting fraud. **The dollar figure is an estimate and is labelled as one.** It sums the sale prices recorded on the sale events of the declined units, per currency, and never across currencies. ``declined_units_with_price`` states how much of the population it actually covers; ``never_sold`` and ``unknown_serial`` can never carry a price, because both mean this organization recorded no sale for that unit. **Dates are inclusive and resolved in ``time_zone``** (default UTC), so ``end`` covers the whole of that day locally. A span longer than 366 days is **clamped** to the most recent 366 days and flagged with ``period_truncated``, and reversed bounds are ordered — neither is an error, and the window actually used is always echoed back. An unrecognised ``time_zone`` *is* rejected (422): falling back to UTC would move a retailer's month boundary and land real transactions in the wrong month. Results are computed only from the authenticated organization's own outcomes. Caller must hold an OWNER or MANAGER membership. # Get capture job Source: https://docs.closient.com/api-reference/epcis/epcis-20/get-capture-job /openapi/openapi-epcis.json get /epcis/api/2.0/capture/{capture_id} Poll a capture job. ``running`` is false once processing has finished; ``success`` is true only when every event in the document was captured. ``errors`` carries RFC 9457 problem objects, each with ``sequenceInEPCISDoc`` naming the indices of the offending events in the document you submitted — which is what makes a rejection on a large file actionable. A job belonging to another organization is indistinguishable from one that does not exist: both return ``404``, so this endpoint never confirms that someone else's capture ID is real. # Get EPCIS event by ID Source: https://docs.closient.com/api-reference/epcis/epcis-20/get-epcis-event-by-id /openapi/openapi-epcis.json get /epcis/api/2.0/events/{event_id} Retrieve a single EPCIS event by its UUID. The response body is an ``EPCISQueryDocument`` (GS1 EPCIS 2.0 §8.2.5) whose ``eventList`` holds exactly that one event, in the same JSON-LD representation ``/events`` returns. The event is only visible to OWNER / MANAGER members of the organization that captured it; other organizations receive ``404`` (no leak of existence). # Historical KDE/CTE snapshot for one lot Source: https://docs.closient.com/api-reference/epcis/epcis-20/historical-kdecte-snapshot-for-one-lot /openapi/openapi-epcis.json get /epcis/api/2.0/traceability/lots/{lot_id}/as-of Reconstructs the FSMA 204 KDE/CTE state this organization had **on file** for one lot as of a past timestamp — the question an FDA investigator or auditor actually asks during a § 1.1455(c) records request: 'what did your records show on the date we asked', not 'what do they show now'. The CriticalTrackingEvent ledger is immutable/append-only (create + retention-guarded delete only), so this replays it up to ``as_of``: **filtered by when each CTE row was captured/recorded, not by the underlying event's own date**. A CTE recorded (captured into the system) after ``as_of`` is excluded from the snapshot even when its ``event_date`` predates ``as_of`` — it genuinely was not on file yet at that point in time. Pass the current time as ``as_of`` to see everything on file today (equivalent to the unfiltered ledger). Same auth + visibility semantics as the FDA traceability export and blast-radius endpoints — the requesting user must be an OWNER or MANAGER of the organization that owns the lot's brand; other orgs receive ``404`` (no leak of lot existence). # List capture jobs Source: https://docs.closient.com/api-reference/epcis/epcis-20/list-capture-jobs /openapi/openapi-epcis.json get /epcis/api/2.0/capture List this organization's capture jobs, newest first, as a bare JSON array of capture jobs (GS1 EPCIS 2.0 ``GET /capture``). Scoped to the caller's organization; another tenant's jobs are never returned. # List supported event types Source: https://docs.closient.com/api-reference/epcis/epcis-20/list-supported-event-types /openapi/openapi-epcis.json get /epcis/api/2.0/eventTypes Public capability-discovery endpoint per GS1 EPCIS 2.0 §8.2.4. Returns the fixed set of event type tokens this server accepts on capture and exposes on query: ``ObjectEvent``, ``AggregationEvent``, ``TransactionEvent``, ``TransformationEvent``, ``AssociationEvent``. No authentication required — the e2e routability probe (``e2e/tests/test_epcis.py``) relies on this to detect environments that don't expose the EPCIS API at all. # One-click FSMA 204 readiness score for one lot Source: https://docs.closient.com/api-reference/epcis/epcis-20/one-click-fsma-204-readiness-score-for-one-lot /openapi/openapi-epcis.json get /epcis/api/2.0/traceability/lots/{lot_id}/fsma-204-readiness Composes a lot's FSMA 204 readiness score server-side in one call, instead of the caller chaining several ``GET /epcis/api/2.0/events`` queries by hand (the manual audit ``check-fsma-204-readiness.mdx`` walks through). Scores KDE completeness across every CriticalTrackingEvent on file for the lot using the same corrected rubric the FSMA 204 compliance dashboard uses (C-5398): a trading-partner GLN captured but never resolved to a registered TradingPartner counts a CTE incomplete, not satisfied — it is not enough for a GLN to merely be present. A lot with zero CTEs on file scores ``0.0`` (not '' or null) — a product existing in the catalog is not the same as it being traceability-ready. Does not (yet) check whether the lot has every CTE type its food category requires; see the skill doc's remaining Known gaps. Same auth + visibility semantics as the other traceability endpoints — the requesting user must be an OWNER or MANAGER of the organization that owns the lot's brand; other orgs receive ``404`` (no leak of lot existence). # Quantified downstream footprint for one lot Source: https://docs.closient.com/api-reference/epcis/epcis-20/quantified-downstream-footprint-for-one-lot /openapi/openapi-epcis.json get /epcis/api/2.0/traceability/lots/{lot_id}/blast-radius Walks the forward CriticalTrackingEvent chain from the given lot and returns aggregate counts (downstream lots, serials, ship-to GLNs, trading-partner recipients). Same auth + visibility semantics as the FDA traceability export — the requesting user must be an OWNER or MANAGER of the organization that owns the lot's brand. Designed for recall-impact triage (C-734): given a suspect lot, how far has it spread and to whom? Counts are computed over events visible to the requesting organization only. # Query EPCIS events Source: https://docs.closient.com/api-reference/epcis/epcis-20/query-epcis-events /openapi/openapi-epcis.json get /epcis/api/2.0/events Query events using EPCIS 2.0 SimpleEventQuery parameters (GS1 EPCIS 2.0 §8.2.7 and §8.2.8). Filter by event type, time window, CBV business step or disposition, read point, business location, action, or by EPC reference. Results are returned as a JSON-LD ``EPCISQueryDocument`` (§8.2.5) with the standard ``@context``, ``type``, and ``epcisBody.queryResults.eventList`` envelope. Pagination follows the REST binding: while more results remain, the ``Link`` response header carries the absolute URL of the next page with ``rel="next"``, and ``GS1-Next-Page-Token-Expires`` says how long its ``nextPageToken`` is guaranteed valid; the last page has no ``Link``. The ``GS1-Next-Page-Token`` header is still sent but deprecated. Caller must hold OWNER or MANAGER on an organization; results are scoped to events captured by that organization. # Verify a serialized unit for returns Source: https://docs.closient.com/api-reference/epcis/epcis-20/verify-a-serialized-unit-for-returns /openapi/openapi-epcis.json get /epcis/api/2.0/verification/serial Decide whether a scanned GTIN + serial was sold by the authenticated organization, when, under which transaction, and whether it has already been returned. Intended for a return desk scanner or POS plugin: one call, one decision object. Decisions are computed **only** from the querying organization's own EPCIS events — another retailer's sale of the same GTIN + serial is never visible here, and a unit this organization has no events for is reported as ``unknown_serial`` rather than falling back to anyone else's history. Recall status is the one deliberate exception: it is public product-safety information, so an open recall covering the unit is reported regardless of which organization published it. The lifecycle states ride GS1 CBV 2.0 dispositions — ``retail_sold`` for a sale (bizStep ``retail_selling``) and ``returned`` for a return (bizStep ``returning``). Caller must hold an OWNER or MANAGER membership on the organization. # List webhook deliveries Source: https://docs.closient.com/api-reference/integrations/webhook-deliveries/list-webhook-deliveries /openapi/openapi-integrations.json get /integrations/api/v1/webhooks/deliveries/ Recent delivery attempts across **every** endpoint on the authenticated organization, ordered most-recent first. Each row is a single attempt — replays produce a new row rather than mutating the prior one. Filter by endpoint, lifecycle status, event type, and a ``created_at`` date range. Page size is fixed at 50. # Replay a dead-lettered delivery Source: https://docs.closient.com/api-reference/integrations/webhook-deliveries/replay-a-dead-lettered-delivery /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/deliveries/{delivery_id}/replay/ Re-queue a ``dead_letter`` delivery for another attempt. Creates a brand-new delivery record (the original row is preserved for audit) and waits for the first attempt to finish before responding; subsequent retries — if any — happen asynchronously and surface in the deliveries list. Replaying a delivery that is not in the ``dead_letter`` state returns 422. # Create a webhook endpoint Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/create-a-webhook-endpoint /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/endpoints/ Register a new HTTPS webhook endpoint on the authenticated organization. The full signing secret is returned **only** on this create response — store it on the customer side immediately; subsequent ``GET``/``LIST`` responses return a masked form. The endpoint does **not** start active. It is created in ``state: pending_verification`` and Closient immediately sends the CloudEvents abuse-protection handshake (an ``OPTIONS`` request carrying ``WebHook-Request-Origin``) to your URL; it becomes ``active`` only once your endpoint answers with a matching ``WebHook-Allowed-Origin``. If it does not — commonly because the receiver is not deployed yet — this call still returns ``201`` with the reason on ``verification_error``, and you retry with ``POST /{endpoint_id}/verify/``. Nothing is delivered until it passes. A URL that fails address policy (not HTTPS, carries credentials, or resolves into private/loopback/link-local/multicast/reserved/cloud-metadata space) is rejected outright with ``422`` and the reason on ``url``. Both per-organization caps — endpoint count and distinct destination hosts — are enforced here and count paused and unverified endpoints. # Delete a webhook endpoint Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/delete-a-webhook-endpoint /openapi/openapi-integrations.json delete /integrations/api/v1/webhooks/endpoints/{endpoint_id}/ Soft-delete (deactivate) the endpoint. Historical delivery records are retained for audit and can still be queried via the deliveries-list endpoint. New events will not be enqueued for this endpoint after deletion. # Get a webhook endpoint Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/get-a-webhook-endpoint /openapi/openapi-integrations.json get /integrations/api/v1/webhooks/endpoints/{endpoint_id}/ Retrieve a single webhook endpoint by UUID. The signing secret is returned masked (``...``); to obtain the full secret, rotate via ``POST /{endpoint_id}/rotate-secret/``. # List webhook endpoints Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/list-webhook-endpoints /openapi/openapi-integrations.json get /integrations/api/v1/webhooks/endpoints/ List every webhook endpoint on the authenticated organization. Signing secrets are masked. Supports ``?metadata[key]=value`` exact-match filtering on the developer-attached metadata bag (see ``MetadataReadMixin``). Page-size is fixed at 50; use ``?page=N`` to walk the paginated set. # Pause a webhook endpoint Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/pause-a-webhook-endpoint /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/endpoints/{endpoint_id}/pause/ Stop deliveries without deleting the endpoint or its filters. Equivalent to ``PATCH`` with ``is_active: false``, except it also records ``state: paused`` (rather than ``disabled``) so the dashboard can distinguish a deliberate pause from an auto-disable or a delete. Resume with ``POST /{endpoint_id}/resume/``. # Resume a paused webhook endpoint Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/resume-a-paused-webhook-endpoint /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/endpoints/{endpoint_id}/resume/ Resume deliveries on an endpoint previously paused via ``POST /{endpoint_id}/pause/``. Returns 422 if the endpoint is not currently ``paused`` (for example, if it was auto-disabled for delivery failures — re-activate it via ``PATCH`` with ``is_active: true`` once the underlying issue is fixed). # Rotate signing secret Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/rotate-signing-secret /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/endpoints/{endpoint_id}/rotate-secret/ Generate a new signing secret and return it **in full**. Initiates a **24-hour dual-signature grace period**: during this window every delivery's ``X-Closient-Signature`` header carries both ``v1=`` (signed with the new secret) and ``v1old=`` (signed with the previous secret) so customers can switch over without downtime. After 24 hours the previous secret is discarded and only ``v1=`` is sent. # Send test event Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/send-test-event /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/endpoints/{endpoint_id}/test/ Send a synthetic ``ping`` event to the endpoint **synchronously** and return the first attempt's result. Bypasses the retry queue — this is for verifying URL reachability, signature verification, and customer-side handler wiring during integration. Subscribed event types are not consulted; the test event is delivered regardless of ``event_types``. # Update a webhook endpoint Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/update-a-webhook-endpoint /openapi/openapi-integrations.json patch /integrations/api/v1/webhooks/endpoints/{endpoint_id}/ Partial update — every field on the request body is optional and omitted fields are left untouched. ``metadata`` follows Stripe-style merge semantics (``{}``/``null`` clears, empty-string values delete a key). The signing secret cannot be rotated through this endpoint; use the dedicated rotate-secret endpoint instead. # Verify endpoint ownership Source: https://docs.closient.com/api-reference/integrations/webhook-endpoints/verify-endpoint-ownership /openapi/openapi-integrations.json post /integrations/api/v1/webhooks/endpoints/{endpoint_id}/verify/ Re-run the **CloudEvents abuse-protection handshake** against this endpoint's URL and activate it if the destination consents. This is the way out of ``state: pending_verification`` — the state every endpoint is created in, and returns to whenever its ``url`` changes. Closient sends ``OPTIONS`` to your URL with: * ``WebHook-Request-Origin`` — the Closient origin asking for consent * ``WebHook-Request-Rate`` — the most requests per minute we may send Answer ``200`` with ``WebHook-Allowed-Origin`` set to that origin (or ``*``), and optionally ``WebHook-Allowed-Rate``. Anything else leaves the endpoint inactive and returns ``422`` with the reason, which is also recorded on ``verification_error``. Nothing is delivered to an endpoint that has not completed this handshake — registering a URL is not evidence that its owner wants our traffic. Spec: https://github.com/cloudevents/spec/blob/main/cloudevents/http-webhook.md # List subscribable event types Source: https://docs.closient.com/api-reference/integrations/webhook-event-catalog/list-subscribable-event-types /openapi/openapi-integrations.json get /integrations/api/v1/webhooks/event-types/ The full event catalog: every event type you may put in an endpoint's ``event_types``, what each one means, which envelope version introduced it, and which envelope versions exist. Subscribing to a value not listed here returns 422 rather than being silently accepted — so a typo costs you an error, never a subscription that quietly never fires. ``planned`` lists event types on the roadmap. They are **not** subscribable yet and are published only so you can see what is coming; each names the issue that will wire it. # Replay events from a cursor (gap-fill on reconnect) Source: https://docs.closient.com/api-reference/integrations/webhook-events/replay-events-from-a-cursor-gap-fill-on-reconnect /openapi/openapi-integrations.json get /integrations/api/v1/webhooks/events/ Walk your organization's durable event log forward, **oldest first**. This is how a subscriber that was offline recovers: every event is logged when it is emitted, before any delivery is attempted, so an outage — yours or ours — leaves a record you can replay rather than a silent gap. The loop is: persist the ``cursor`` of the last event you durably processed; on reconnect call this endpoint with ``?after=``; process the page; repeat with the last cursor of that page until it comes back short. The walk is gap-free and duplicate-free even when two events share a timestamp, because the cursor is a keyset over ``(occurred_at, id)`` rather than a timestamp. ``envelope`` is byte-identical to what a live delivery would have POSTed at that version, so replayed events need no special casing in your handler. ``event_id`` is stable across the live delivery and every replay — deduplicate on it. # Resolve a coordinate to a country Source: https://docs.closient.com/api-reference/locations/countries/resolve-a-coordinate-to-a-country /openapi/openapi-locations.json get /locations/api/v1/countries/resolve Resolves `lat`/`lon` to an ISO 3166-1 alpha-2 country with the same offline point-in-polygon rule Closient's own store importers use (Natural Earth admin-0, no third-party call). Use it to earn `country_derived: true` on a store row: send the returned `country` as the row's `country` and set `country_derived` only when `method` is not `none` and `flagged` is false. A coordinate that resolves to nothing is a successful `200` with `country: null` and `method: none`, not an error. # Approximate caller location Source: https://docs.closient.com/api-reference/locations/store-locator-public/approximate-caller-location /openapi/openapi-locations.json get /locations/api/v1/public/geo Returns the caller's approximate location, derived server-side from the edge network's view of the connection (Cloudflare `CF-*` visitor-location headers). Intended as the store locator's fallback when the browser's `navigator.geolocation` permission is denied or times out, so the widget never has to hand the visitor's IP to a third-party geolocation service. City-level accuracy at best. All fields are `null` when the edge did not supply a location — callers must handle that and fall back to asking the visitor for an address. No IP address is stored or returned. The response is `private, no-store`: it is derived from the caller's own connection, so a shared cache in front of it would serve one visitor's city to the next. # Find nearby locations Source: https://docs.closient.com/api-reference/locations/store-locator-public/find-nearby-locations /openapi/openapi-locations.json get /locations/api/v1/public/locations Returns the organization's locations within `radius` km of (`lat`, `lon`), sorted by ascending great-circle distance. Authenticated via a publishable API key (`cpk_*`) in the `X-API-Key` header — the same key embedded in the public store-locator widget. Coordinates are WGS 84 / EPSG:4326 (the standard used by browsers' `navigator.geolocation` and Google Maps); PostGIS computes distance on the geography type (`ST_Distance` over a spheroid), so values are correct globally and not just for short distances. Use `radius` to bound the spatial window and `limit` to cap the result count after distance sort. # Geocode a free-text address Source: https://docs.closient.com/api-reference/locations/store-locator-public/geocode-a-free-text-address /openapi/openapi-locations.json get /locations/api/v1/public/geocode Resolves a free-text place description (city, postal code, or street address) to coordinates you can pass to `/locations` as `lat`/`lon`. The lookup runs server-side against Closient's configured geocoder and is cached, so the visitor's browser never contacts the geocoding provider directly. `lat`/`lon` are `null` when nothing matched — that is a successful `200`, not an error. A `502` means the geocoding provider itself was unreachable (`retryable: true`). # Track widget analytics events Source: https://docs.closient.com/api-reference/locations/store-locator-public/track-widget-analytics-events /openapi/openapi-locations.json post /locations/api/v1/public/widget-events Fire-and-forget telemetry for the embeddable store locator widget. Designed for `navigator.sendBeacon()` and background `fetch()` calls — no auth, no rate limit, always returns `204 No Content`. The body is structured-logged server-side (event type, `cpk_*` key prefix, referrer, query, and clicked location's `short_id`) but is not persisted to the database. Use this for usage funnels (load -> search -> click), not for billable events. # Create a notification channel Source: https://docs.closient.com/api-reference/notifications/notifications/create-a-notification-channel /openapi/openapi-notifications.json post /notifications/api/v1/notifications/channels Register a new notification channel for the authenticated user. Returns the channel with ``verified=false``; a verification flow (email link, SMS code) must complete before notifications are delivered. Returns 400 if ``channel_type`` is not one of the supported values, or if it is ``sms`` — SMS channels are created only by completing the phone verification flow, never by this endpoint. # Delete a notification channel Source: https://docs.closient.com/api-reference/notifications/notifications/delete-a-notification-channel /openapi/openapi-notifications.json delete /notifications/api/v1/notifications/channels/{channel_id} Permanently delete a notification channel. To temporarily pause delivery without losing the channel configuration, PATCH with ``enabled=false`` instead. # Get a notification channel Source: https://docs.closient.com/api-reference/notifications/notifications/get-a-notification-channel /openapi/openapi-notifications.json get /notifications/api/v1/notifications/channels/{channel_id} Retrieve a single notification channel by ID. Returns 404 if the channel does not exist or is owned by another user. # List in-app notifications Source: https://docs.closient.com/api-reference/notifications/notifications/list-in-app-notifications /openapi/openapi-notifications.json get /notifications/api/v1/notifications/in-app List in-app notifications for the authenticated user, newest first. Supports filtering by read status via ``unread_only``. Pagination metadata includes ``unread_count`` so clients can render an inbox badge without a second request. # List notification channels Source: https://docs.closient.com/api-reference/notifications/notifications/list-notification-channels /openapi/openapi-notifications.json get /notifications/api/v1/notifications/channels List every notification channel registered to the authenticated user. Supports filtering by ``channel_type`` and ``enabled`` status, and ordering by ``label`` or ``created`` (prefix with ``-`` for descending). # Mark a notification as read Source: https://docs.closient.com/api-reference/notifications/notifications/mark-a-notification-as-read /openapi/openapi-notifications.json post /notifications/api/v1/notifications/in-app/{notification_id}/read Mark a single in-app notification as read. Idempotent — calling this endpoint on an already-read notification is a no-op. Returns 404 if the notification does not exist or is owned by another user. # Mark all notifications as read Source: https://docs.closient.com/api-reference/notifications/notifications/mark-all-notifications-as-read /openapi/openapi-notifications.json post /notifications/api/v1/notifications/in-app/read-all Mark every unread in-app notification for the authenticated user as read. Returns the number of notifications updated. Already-read notifications are skipped. # Notification system health Source: https://docs.closient.com/api-reference/notifications/notifications/notification-system-health /openapi/openapi-notifications.json get /notifications/api/v1/notifications/health Aggregate health of the notification delivery pipeline. Reports a status verdict (``healthy`` / ``degraded`` / ``unhealthy``) computed from the trailing-hour failure rate, plus delivery counters for the last hour and unsubscribe counters for the last 24 hours. Used by uptime monitors and the internal ops dashboard. # Update a notification channel Source: https://docs.closient.com/api-reference/notifications/notifications/update-a-notification-channel /openapi/openapi-notifications.json patch /notifications/api/v1/notifications/channels/{channel_id} Partially update a notification channel. Only ``enabled`` and ``label`` are mutable; channel-type and verification status cannot be changed via this endpoint. # Carrier delivery-receipt (DLR) callback Source: https://docs.closient.com/api-reference/notifications/sms-webhooks/carrier-delivery-receipt-dlr-callback /openapi/openapi-notifications.json post /notifications/api/v1/sms/webhooks/status/ Unauthenticated carrier delivery-receipt callback. Verifies the signature against the raw body BEFORE parsing (401 on failure), then updates the matching SMSMessage delivery status. # Carrier inbound-SMS callback Source: https://docs.closient.com/api-reference/notifications/sms-webhooks/carrier-inbound-sms-callback /openapi/openapi-notifications.json post /notifications/api/v1/sms/webhooks/inbound/ Unauthenticated carrier callback for inbound SMS on the 844/833 lines. Verifies a Flowroute HMAC (or voip.ms shared-secret) against the raw body BEFORE parsing; invalid/missing signature returns 401. Handles STOP/HELP/START keywords and records the inbound message. # Unified Telnyx webhook (inbound + delivery-receipt) Source: https://docs.closient.com/api-reference/notifications/sms-webhooks/unified-telnyx-webhook-inbound-+-delivery-receipt /openapi/openapi-notifications.json post /notifications/api/v1/sms/webhooks/telnyx/ Single webhook URL for the Telnyx messaging profile (C-4127) — Telnyx posts both inbound messages and delivery receipts to one configured URL, unlike Flowroute/voip.ms which are split across /inbound/ and /status/. Dispatches on data.event_type: `message.received` handles inbound STOP/HELP/START; any other event type (e.g. `message.finalized`) is treated as a delivery-status update. Verifies the Telnyx Ed25519 signature against the raw body BEFORE parsing; invalid/missing signature returns 401. # API Reference Source: https://docs.closient.com/api-reference/overview Interactive documentation for all Closient APIs. Closient provides 18 domain-specific APIs. Each has its own OpenAPI specification and interactive playground below. ## APIs Users, organizations, memberships, API keys. RBAC with Owner, Manager, Billing, and Editor roles. GTIN-based product catalog. Supports GTIN-8/12/13/14 with automatic normalization to GTIN-14. Includes data provenance, confidence scores, and GPC classification. Brand ownership, claiming, and management. Brands are linked to organizations and products. Physical store and service locations with PostGIS coordinates. Used for distance-based search ranking. Offers, inventory, pricing, and retailer data. Includes regional overrides and inventory confidence levels. Hybrid search with semantic embeddings (1024-dim), lexical matching, and geographic proximity. Conversational sessions with constraint accumulation. GTIN resolution via Digital Link URIs. Configurable rules with scope hierarchy, time-based routing, and custom destinations. Supply chain event capture and query per ISO/IEC 19987:2024. Five event types with full CBV 2.0 vocabulary. POS connections (Shopify, Square, Clover, Lightspeed) and webhook management with signed payloads and delivery tracking. Stripe-based subscriptions, resolver tiers (Free/Business), and usage metering for API calls, lookups, and webhook deliveries. Barcode and product image scanning. Supports barcode, image, and manual capture modes with vision extraction proposals. Product recalls, safety notices, and supply chain traceability. Product certification management and verification. Sustainability data, certifications, and environmental tracking. Multi-channel notification delivery (email, Slack, SMS). Analytics, performance snapshots, and observability data. Product readiness checks and content management. ## Common Conventions See [Authentication](/authentication), [Rate Limits](/rate-limits), and [Errors](/errors) for shared patterns. | Convention | Details | | - | - | | Base URL | `https://www.closient.com` | | Auth | `X-API-Key: __` | | Rate limit | 300/min, 10,000/day per key (configurable) | | Pagination | `?page=1&page_size=25` (max 100) | | Errors | `{ "error": { "code", "message", "status", "details" } }` | | IDs | nanoid: 12 chars, alphabet `aceghjkpqrtwxy346789` | | Timestamps | ISO 8601 UTC | # Create accessory Source: https://docs.closient.com/api-reference/products/accessories/create-accessory /openapi/openapi-products.json post /products/api/v1/accessories List one of the organization's products as an accessory of another. Requires CONTRIBUTE on the organization. 404 if either GTIN isn't a product the organization owns; 409 if the pair already exists; 422 if a product is made its own accessory. Nothing is created on error. # Delete accessory Source: https://docs.closient.com/api-reference/products/accessories/delete-accessory /openapi/openapi-products.json delete /products/api/v1/accessories/{accessory_id} Remove an accessory. Neither product is touched. Requires CONTRIBUTE on the owning organization. # Get accessory Source: https://docs.closient.com/api-reference/products/accessories/get-accessory /openapi/openapi-products.json get /products/api/v1/accessories/{accessory_id} Fetch one accessory. 404 if it doesn't exist or the caller can't see it. # List accessories Source: https://docs.closient.com/api-reference/products/accessories/list-accessories /openapi/openapi-products.json get /products/api/v1/accessories List accessories across the organizations the caller can see, ordered by product GTIN. Filter by organization or by the GTIN of the product the accessory belongs to. Paginated. # Update accessory Source: https://docs.closient.com/api-reference/products/accessories/update-accessory /openapi/openapi-products.json patch /products/api/v1/accessories/{accessory_id} Point the accessory at a different product of the same organization. The product the accessory belongs to cannot change; delete and recreate to move it. Requires CONTRIBUTE on the owning organization. # Create app link Source: https://docs.closient.com/api-reference/products/app-links/create-app-link /openapi/openapi-products.json post /products/api/v1/app-links Create an app link on a product. Requires CONTRIBUTE on the organization that owns the product; 404 if the GTIN isn't a product the caller can see. # Delete app link Source: https://docs.closient.com/api-reference/products/app-links/delete-app-link /openapi/openapi-products.json delete /products/api/v1/app-links/{link_id} Delete an app link. Requires CONTRIBUTE on the owning organization. # Get app link Source: https://docs.closient.com/api-reference/products/app-links/get-app-link /openapi/openapi-products.json get /products/api/v1/app-links/{link_id} Fetch one app link. 404 if it doesn't exist or the caller can't see it. # List app links Source: https://docs.closient.com/api-reference/products/app-links/list-app-links /openapi/openapi-products.json get /products/api/v1/app-links List app links on products owned by the organizations the caller can see, in hosted-page order (priority, then platform, then name). Filter by product GTIN or platform. Paginated. # Update app link Source: https://docs.closient.com/api-reference/products/app-links/update-app-link /openapi/openapi-products.json patch /products/api/v1/app-links/{link_id} Partial update: only the keys in the body are applied. Requires CONTRIBUTE on the owning organization. # Generate a single 1D barcode Source: https://docs.closient.com/api-reference/products/codes/generate-a-single-1d-barcode /openapi/openapi-products.json post /products/api/v1/barcode/ Render a 1D barcode for a GTIN. Symbology is auto-selected from GTIN length unless overridden via ``barcode_type``. Returns image bytes (``image/svg+xml``, ``image/png``, ``application/postscript``, or ``application/pdf``). **HRI (C-5840)**: ``barcode_type='gs1_128'`` accepts ``hri`` / ``hri_preset`` and picks up the calling organization's default preset, as ``POST /qr/`` does; ``module_width`` is the X-dimension. Every other type returns 422 for an explicit ``hri`` / ``hri_preset`` and is never affected by an organization default. **Caching**: responses are deterministic given the request body. The endpoint sets ``Cache-Control: public, max-age=2592000`` (30 days) and a strong ``ETag`` over the image bytes. When a named or default preset supplies the HRI configuration the response is cached ``private`` instead. Clients sending ``If-None-Match`` with a matching ETag get ``304 Not Modified``. # Generate a single GS1 DataMatrix Source: https://docs.closient.com/api-reference/products/codes/generate-a-single-gs1-datamatrix /openapi/openapi-products.json post /products/api/v1/datamatrix/ Render a GS1 DataMatrix for a GTIN plus any subset of (lot, serial, expiry). At least one of lot or serial is required. Returns image bytes (``image/svg+xml``, ``image/png``, or ``application/pdf``). **HRI (C-5840)**: pass ``hri`` (inline options), ``hri_preset`` (a saved preset name), or neither — in which case the calling organization's default preset applies, if one is set. Composing HRI requires ``xdim_mm``; the HRI text follows the encoding order (01), (17), (10), (21). **Caching**: responses are deterministic given the request body. The endpoint sets ``Cache-Control: public, max-age=2592000`` (30 days) and a strong ``ETag`` over the image bytes. When a named or default preset supplies the HRI configuration the response is cached ``private`` instead. Clients sending ``If-None-Match`` with a matching ETag get ``304 Not Modified``. # Create consumable Source: https://docs.closient.com/api-reference/products/consumables/create-consumable /openapi/openapi-products.json post /products/api/v1/consumables List one of the organization's products as a consumable of another. Requires CONTRIBUTE on the organization. 404 if either GTIN isn't a product the organization owns; 409 if the pair already exists; 422 if a product is made its own consumable. Nothing is created on error. # Delete consumable Source: https://docs.closient.com/api-reference/products/consumables/delete-consumable /openapi/openapi-products.json delete /products/api/v1/consumables/{consumable_id} Remove a consumable. Neither product is touched. Requires CONTRIBUTE on the owning organization. # Get consumable Source: https://docs.closient.com/api-reference/products/consumables/get-consumable /openapi/openapi-products.json get /products/api/v1/consumables/{consumable_id} Fetch one consumable. 404 if it doesn't exist or the caller can't see it. # List consumables Source: https://docs.closient.com/api-reference/products/consumables/list-consumables /openapi/openapi-products.json get /products/api/v1/consumables List consumables across the organizations the caller can see, ordered by product GTIN. Filter by organization or by the GTIN of the product the consumable belongs to. Paginated. # Update consumable Source: https://docs.closient.com/api-reference/products/consumables/update-consumable /openapi/openapi-products.json patch /products/api/v1/consumables/{consumable_id} Partial update: only the keys in the body are applied. ``consumable_gtin`` points the consumable at a different product of the same organization. The product the consumable belongs to cannot change; delete and recreate to move it. An empty body is a 422. Requires CONTRIBUTE on the owning organization. # Parse a GS1 Digital Link URL into structured AIs Source: https://docs.closient.com/api-reference/products/digital-link/parse-a-gs1-digital-link-url-into-structured-ais /openapi/openapi-products.json post /products/api/v1/digital-link/parse Accept a URL string; return the structured GS1 Digital Link components (GTIN, lot, expiry, serial, plus any other AIs found in the path). Returns `is_valid: false` when the URL is not a recognisable Digital Link rather than 4xx-erroring — callers can use a single endpoint to triage arbitrary scanned URLs. # Create firmware Source: https://docs.closient.com/api-reference/products/firmware/create-firmware /openapi/openapi-products.json post /products/api/v1/firmware Create a firmware release and show it on the listed products. Requires CONTRIBUTE on the organization that owns them; 404 if any GTIN isn't one of its products, and nothing is created. # Delete firmware Source: https://docs.closient.com/api-reference/products/firmware/delete-firmware /openapi/openapi-products.json delete /products/api/v1/firmware/{firmware_id} Delete a firmware release. The products it was shown on are untouched. Requires CONTRIBUTE on the owning organization. # Get firmware Source: https://docs.closient.com/api-reference/products/firmware/get-firmware /openapi/openapi-products.json get /products/api/v1/firmware/{firmware_id} Fetch one firmware release with its products. 404 if it doesn't exist or the caller can't see it. # List firmware Source: https://docs.closient.com/api-reference/products/firmware/list-firmware /openapi/openapi-products.json get /products/api/v1/firmware List firmware on products owned by the organizations the caller can see, newest release first. Filter by product GTIN, latest or stable. Paginated. # Update firmware Source: https://docs.closient.com/api-reference/products/firmware/update-firmware /openapi/openapi-products.json patch /products/api/v1/firmware/{firmware_id} Partial update: only the keys in the body are applied. ``gtins``, when present, replaces the products the firmware is shown on. Requires CONTRIBUTE on the owning organization. # Archive an HRI preset Source: https://docs.closient.com/api-reference/products/hri-presets/archive-an-hri-preset /openapi/openapi-products.json post /products/api/v1/hri-presets/{preset_id}/archive/ Archive a preset so it no longer appears in selection or preset-name/default resolution. # Create an HRI preset Source: https://docs.closient.com/api-reference/products/hri-presets/create-an-hri-preset /openapi/openapi-products.json post /products/api/v1/hri-presets/ Save a new named HRI configuration at version 1, scoped to the caller's organization. 409 if the name is already taken by a non-archived preset in this organization. # List HRI presets Source: https://docs.closient.com/api-reference/products/hri-presets/list-hri-presets /openapi/openapi-products.json get /products/api/v1/hri-presets/ List the org's HRI presets, newest-modified first. # Read an HRI preset Source: https://docs.closient.com/api-reference/products/hri-presets/read-an-hri-preset /openapi/openapi-products.json get /products/api/v1/hri-presets/{preset_id}/ Return a single HRI preset by id. Cross-org ids 404. # Update an HRI preset Source: https://docs.closient.com/api-reference/products/hri-presets/update-an-hri-preset /openapi/openapi-products.json patch /products/api/v1/hri-presets/{preset_id}/ Update preset fields. A change to `options` bumps `version`. `is_default=true` clears any prior default for the organization. 409 if renaming collides with another non-archived preset's name in this organization. # Bulk import lots Source: https://docs.closient.com/api-reference/products/import/bulk-import-lots /openapi/openapi-products.json post /products/api/v1/import/lots Upload a file to create or update batch/lot records (GS1 AI 10) in bulk. Required columns: ``gtin`` (also ``barcode`` / ``upc`` / ``ean``) and ``lot_identifier`` (also ``lot`` / ``lot_number`` / ``batch`` / ``batch_number``). Optional columns: ``production_date``, ``expiration_date`` (also ``expiry_date`` / ``use_by``), ``best_before_date``, ``quantity``, ``unit_of_measure`` (a GS1 / UN/ECE Rec 20 code such as ``EA`` or ``KGM``), ``harvest_date``, ``harvest_end_date``, ``resolver_enabled``. Dates are ``YYYY-MM-DD``. Each row's GTIN must already be a product in the authenticated organization's catalog — a lot import never creates products, so a GTIN you do not own is reported as a row error. Re-importing is idempotent: a row for an existing lot updates it, a blank cell leaves the stored value unchanged rather than clearing it, and an unchanged row is counted as skipped. Once a lot is referenced by a Critical Tracking Event its identifier and harvest key data elements are locked (FSMA 204 / 21 CFR 1.1320) and an import that changes them is rejected per row rather than silently rewriting traceability history. Accepts CSV, TSV and XLSX — the uploaded filename's extension selects the parser, so a tab-separated file must be named ``.tsv`` (a ``.csv`` name is parsed with a comma delimiter and will fail with a missing-column error rather than misparse silently). Text uploads must be UTF-8. Maximum upload size is 10 MiB; maximum 10,000 data rows per upload. Excel stores every cell as a number unless the column is formatted as Text, which strips leading zeros from identifiers — format identifier columns as Text. The endpoint returns 200 with a per-row error report even on partial success — only request-level failures (auth, file type, file size, no brand membership) produce 4xx responses. # Bulk import products from CSV Source: https://docs.closient.com/api-reference/products/import/bulk-import-products-from-csv /openapi/openapi-products.json post /products/api/v1/import/csv Upload a file to create or update products in bulk. The CSV must include a GTIN column (also accepts ``barcode``, ``upc``, ``ean``, or ``gtin-8`` / ``gtin-12`` / ``gtin-13`` / ``gtin-14``). Optional columns: ``product_name`` (also ``name`` / ``title``), ``brand``, ``manufacturer``, ``description``, ``image_url`` (also ``image`` / ``photo_url``), ``country_of_origin`` (also ``country`` / ``origin``), ``net_content`` (also ``size`` / ``weight`` / ``volume``). Imported products are claimed for the authenticated user's organization with BRAND_CLAIMED authority. The endpoint returns 200 with a per-row error report even on partial success — only request-level failures (auth, file type, file size, no brand membership) produce 4xx responses. Accepts CSV, TSV and XLSX — the uploaded filename's extension selects the parser, so a tab-separated file must be named ``.tsv`` (a ``.csv`` name is parsed with a comma delimiter and will fail with a missing-column error rather than misparse silently). Text uploads must be UTF-8. Maximum upload size is 10 MiB; maximum 10,000 data rows per upload. Excel stores every cell as a number unless the column is formatted as Text, which strips leading zeros from identifiers — format identifier columns as Text. # Bulk import serial numbers Source: https://docs.closient.com/api-reference/products/import/bulk-import-serial-numbers /openapi/openapi-products.json post /products/api/v1/import/serials Upload a file to create or update serialized-unit records (GS1 AI 21) in bulk. Required columns: ``gtin`` (also ``barcode`` / ``upc`` / ``ean``) and ``serial`` (also ``serial_number`` / ``sn``). Optional columns: ``lot_identifier``, ``production_date``, ``expiration_date``, ``best_before_date``, ``resolver_enabled``. Dates are ``YYYY-MM-DD``. Each row's GTIN must already be a product in the authenticated organization's catalog. ``lot_identifier`` is optional — under GS1 a unit's identity is its SGTIN (GTIN + serial) and the batch is an attribute, so a serial with no lot is a legitimate unbatched unit — but when supplied it must name a lot that is already registered on that product. A serial import never creates lots: a mistyped lot code would split one physical batch into two, and a batch is the FSMA 204 recall unit. Re-importing is idempotent, and a blank cell leaves the stored value unchanged rather than clearing it. Accepts CSV, TSV and XLSX — the uploaded filename's extension selects the parser, so a tab-separated file must be named ``.tsv`` (a ``.csv`` name is parsed with a comma delimiter and will fail with a missing-column error rather than misparse silently). Text uploads must be UTF-8. Maximum upload size is 10 MiB; maximum 10,000 data rows per upload. Excel stores every cell as a number unless the column is formatted as Text, which strips leading zeros from identifiers — format identifier columns as Text. The endpoint returns 200 with a per-row error report even on partial success — only request-level failures (auth, file type, file size, no brand membership) produce 4xx responses. # Download CSV import template Source: https://docs.closient.com/api-reference/products/import/download-csv-import-template /openapi/openapi-products.json get /products/api/v1/import/template Download a sample CSV template with the expected column headers and two example data rows for bulk product import. Returned as ``text/csv`` with a ``Content-Disposition: attachment`` header so browsers save it directly. The template is public — no authentication required — to make it easy to preview the schema before signing up. The same columns apply to a TSV or XLSX upload. # Download lot import template Source: https://docs.closient.com/api-reference/products/import/download-lot-import-template /openapi/openapi-products.json get /products/api/v1/import/lots/template Download a sample CSV template with the expected column headers and two example rows for bulk lot import. Returned as ``text/csv`` with a ``Content-Disposition: attachment`` header so browsers save it directly. Public — no authentication required — so the schema can be previewed before signing up. The same columns apply to a TSV or XLSX upload. # Download serial import template Source: https://docs.closient.com/api-reference/products/import/download-serial-import-template /openapi/openapi-products.json get /products/api/v1/import/serials/template Download a sample CSV template with the expected column headers and two example rows for bulk serial import. Returned as ``text/csv`` with a ``Content-Disposition: attachment`` header so browsers save it directly. Public — no authentication required — so the schema can be previewed before signing up. The second example row leaves ``lot_identifier`` blank, which is a legitimate unbatched serialized unit. The same columns apply to a TSV or XLSX upload. # Poll a bulk label-export job Source: https://docs.closient.com/api-reference/products/labels/poll-a-bulk-label-export-job /openapi/openapi-products.json get /products/api/v1/labels/export/{job_id}/ Returns the status of a previously submitted bulk-label-export job. When the job is complete, the response includes a temporary signed download URL for the artifact and an `expires_at` timestamp. Cross-org polling deliberately returns 404 (not 403) to prevent id enumeration. # Submit a bulk label-export job Source: https://docs.closient.com/api-reference/products/labels/submit-a-bulk-label-export-job /openapi/openapi-products.json post /products/api/v1/labels/export/ Queue a bulk label-export job and return the job id. Polling endpoint (`GET /labels/export/{job_id}/`) returns status and a temporary signed download URL once complete. Small jobs (count <= `LabelExportJob.SYNC_THRESHOLD`) run synchronously inside the request — the very first poll will already report `completed`. Larger jobs queue to Celery and progress through `processing` → `completed`. # Archive a lot template Source: https://docs.closient.com/api-reference/products/lots/archive-a-lot-template /openapi/openapi-products.json post /products/api/v1/lots/templates/{template_id}/archive/ Archive a template so it can no longer generate runs until it is unarchived. # Commit a reserved run Source: https://docs.closient.com/api-reference/products/lots/commit-a-reserved-run /openapi/openapi-products.json post /products/api/v1/lots/runs/{run_id}/commit/ Commit a reserved run: flip its lots to active and lock it into the audit trail. 409 if not reserved. # Create a lot template Source: https://docs.closient.com/api-reference/products/lots/create-a-lot-template /openapi/openapi-products.json post /products/api/v1/lots/templates/ Create a new lot/serial format template at version 1. The `format_string` is validated for the target. # Discard a reserved run Source: https://docs.closient.com/api-reference/products/lots/discard-a-reserved-run /openapi/openapi-products.json post /products/api/v1/lots/runs/{run_id}/discard/ Discard a reserved run. `rollback` deletes the lots and rewinds the counter when safe (else silently burns); `burn` retires the lots and keeps the counter advanced (a `reason` is required). 409 if the run is not reserved. # Generate a lot or serial run Source: https://docs.closient.com/api-reference/products/lots/generate-a-lot-or-serial-run /openapi/openapi-products.json post /products/api/v1/lots/runs/ Reserve a contiguous block of identifiers. **Lot** templates: small batches run inline and return `201` with a `reserved` run; batches above the async threshold (or `force_async=true`) return `202` with a `queued` run a Celery worker completes. A sync hard collision returns `409` with the `hard_collisions` payload. **Serial** templates (`target_field=serial`, C-2712): always async — up to 10,000,000 per run — and always return `202` with a `queued` run; poll `GET /lots/runs/{id}/` or subscribe to the SSE stream for the outcome, and export the reserved serials via `GET /lots/runs/{id}/serials.csv|.jsonl`. In every case the `failed_collision` run is persisted and linkable. # Generate a PTI-compliant case label Source: https://docs.closient.com/api-reference/products/lots/generate-a-pti-compliant-case-label /openapi/openapi-products.json get /products/api/v1/products/{gtin}/lots/{lot_id}/pti-case-label/ Render a Produce Traceability Initiative (PTI) case label for a product + lot: a GS1-128 barcode encoding at minimum GTIN (AI 01) and Batch/Lot Number (AI 10) — the only two AIs PTI itself mandates — plus production date (AI 11) and a single shelf-life date (best-before AI 15 or use-by AI 17, never both) when the lot carries them. GTIN and Lot are also rendered as human-readable text beneath the bars. Returns image bytes (`image/svg+xml`, `image/png`, `application/postscript`, or `application/pdf`). **Caching**: responses are deterministic given the product/lot state. The endpoint sets `Cache-Control: public, max-age=2592000` (30 days) and a strong `ETag`. Clients sending `If-None-Match` with a matching ETag get `304 Not Modified`. # Latest committed run for a product Source: https://docs.closient.com/api-reference/products/lots/latest-committed-run-for-a-product /openapi/openapi-products.json get /products/api/v1/products/{gtin}/last-run/ Return the most recent committed run for a product (by GTIN). 404 when the product has no committed run. # List a template's versions Source: https://docs.closient.com/api-reference/products/lots/list-a-templates-versions /openapi/openapi-products.json get /products/api/v1/lots/templates/{template_id}/versions/ List every version a template has produced runs against, plus the current version, newest first. # List lot runs Source: https://docs.closient.com/api-reference/products/lots/list-lot-runs /openapi/openapi-products.json get /products/api/v1/lots/runs/ List runs newest-first. `status` accepts multiple comma-separated values (e.g. `?status=queued,generating` for active runs). Other filters: `product` (GTIN), `template` (id), `created_by` (user id), and a `created_after`/`created_before` date range. # List lot templates Source: https://docs.closient.com/api-reference/products/lots/list-lot-templates /openapi/openapi-products.json get /products/api/v1/lots/templates/ List the org's lot templates, newest-modified first. Filter by `target_field` and `is_archived`. # Preview a template without persisting Source: https://docs.closient.com/api-reference/products/lots/preview-a-template-without-persisting /openapi/openapi-products.json post /products/api/v1/lots/templates/{template_id}/preview/ Render sample identifiers from the template's starting value without persisting or advancing the counter. # Read a lot run Source: https://docs.closient.com/api-reference/products/lots/read-a-lot-run /openapi/openapi-products.json get /products/api/v1/lots/runs/{run_id}/ Return a single run by id, including its status, generated ids, counter state, and any collisions. # Read a lot template Source: https://docs.closient.com/api-reference/products/lots/read-a-lot-template /openapi/openapi-products.json get /products/api/v1/lots/templates/{template_id}/ Return a single lot template (its latest version) by id. Cross-org ids 404. # Stream a run's status transitions (SSE) Source: https://docs.closient.com/api-reference/products/lots/stream-a-runs-status-transitions-sse /openapi/openapi-products.json get /products/api/v1/lots/runs/{run_id}/events/ Server-Sent-Events stream of a single run's status and progress transitions, for the live run-detail UI. Emits an event whenever `(status, chunks_done, chunks_total)` changes and closes once the run reaches a stop state. Clients may fall back to polling `GET /lots/runs/{id}/` if SSE is impractical in their environment. # Stream a serial run's serials as CSV Source: https://docs.closient.com/api-reference/products/lots/stream-a-serial-runs-serials-as-csv /openapi/openapi-products.json get /products/api/v1/lots/runs/{run_id}/serials.csv Stream every serial reserved by a serial run as CSV (chunked transfer encoding, header row first), read from a Postgres server-side cursor so a 10M-serial export never buffers server-side (C-2712). Columns: `serial,lifecycle_state,resolver_enabled,created`. 422 for a lot run. # Stream a serial run's serials as JSON Lines Source: https://docs.closient.com/api-reference/products/lots/stream-a-serial-runs-serials-as-json-lines /openapi/openapi-products.json get /products/api/v1/lots/runs/{run_id}/serials.jsonl Stream every serial reserved by a serial run as JSON Lines (one object per line, chunked transfer encoding), read from a server-side cursor (C-2712). Each line: `{serial, lifecycle_state, resolver_enabled, created}`. 422 for a lot run. # Update a lot template Source: https://docs.closient.com/api-reference/products/lots/update-a-lot-template /openapi/openapi-products.json patch /products/api/v1/lots/templates/{template_id}/ Update template fields. A format-affecting change (`format_string`, `character_set`, `check_digit_algorithm`, `target_field`) on an in-use template bumps the version and migrates the counter forward, preserving the current sequence value. An unused template updates in place. # Create merchant link Source: https://docs.closient.com/api-reference/products/merchant-links/create-merchant-link /openapi/openapi-products.json post /products/api/v1/merchant-links Create a merchant link on a product. Requires CONTRIBUTE on the organization that owns the product; 404 if the GTIN isn't a product the caller can see. # Delete merchant link Source: https://docs.closient.com/api-reference/products/merchant-links/delete-merchant-link /openapi/openapi-products.json delete /products/api/v1/merchant-links/{link_id} Delete a merchant link. Requires CONTRIBUTE on the owning organization. # Get merchant link Source: https://docs.closient.com/api-reference/products/merchant-links/get-merchant-link /openapi/openapi-products.json get /products/api/v1/merchant-links/{link_id} Fetch one merchant link. 404 if it doesn't exist or the caller can't see it. # List merchant links Source: https://docs.closient.com/api-reference/products/merchant-links/list-merchant-links /openapi/openapi-products.json get /products/api/v1/merchant-links List merchant links on products owned by the organizations the caller can see, in hosted-page order (name). Filter by product GTIN. Paginated. # Update merchant link Source: https://docs.closient.com/api-reference/products/merchant-links/update-merchant-link /openapi/openapi-products.json patch /products/api/v1/merchant-links/{link_id} Partial update: only the keys in the body are applied. Requires CONTRIBUTE on the owning organization. # Product compliance panel for the embeddable PIP widget Source: https://docs.closient.com/api-reference/products/pip-embed-public/product-compliance-panel-for-the-embeddable-pip-widget /openapi/openapi-products.json get /products/api/v1/public/pip/{gtin} Public, unauthenticated read powering `/widget/v1/pip-panel.js`. Returns recall status, allergens, certifications and the printed ingredient label for one GTIN, reduced to the disclosure level the hosted page would serve an anonymous visitor. # Add product family member Source: https://docs.closient.com/api-reference/products/product-families/add-product-family-member /openapi/openapi-products.json post /products/api/v1/families/{family_id}/members Add a product as a variant. The product must be owned by the family's organization (404 otherwise); 409 if it is already a member, or a variant in another family (a product belongs to at most one). ``is_default`` replaces any previous default. # Create product family Source: https://docs.closient.com/api-reference/products/product-families/create-product-family /openapi/openapi-products.json post /products/api/v1/families Create an empty variant family, then add members with ``POST /families/{family_id}/members``. Requires CONTRIBUTE on the organization. 409 if ``external_ref`` is already used there. # Delete product family Source: https://docs.closient.com/api-reference/products/product-families/delete-product-family /openapi/openapi-products.json delete /products/api/v1/families/{family_id} Delete a family and its memberships. The member products are untouched. Requires CONTRIBUTE on the owning organization. # Get product family Source: https://docs.closient.com/api-reference/products/product-families/get-product-family /openapi/openapi-products.json get /products/api/v1/families/{family_id} Fetch one family with its members. 404 if it doesn't exist or the caller can't see it. # Get product family member Source: https://docs.closient.com/api-reference/products/product-families/get-product-family-member /openapi/openapi-products.json get /products/api/v1/families/{family_id}/members/{member_id} Fetch one member of a family. # List product families Source: https://docs.closient.com/api-reference/products/product-families/list-product-families /openapi/openapi-products.json get /products/api/v1/families List variant families across the organizations the caller can see, ordered by name. Filter by organization, brand, member GTIN or ``external_ref`` (how an importer finds its own family on a re-run). Paginated. # List product family members Source: https://docs.closient.com/api-reference/products/product-families/list-product-family-members /openapi/openapi-products.json get /products/api/v1/families/{family_id}/members List a family's members in display order. # Remove product family member Source: https://docs.closient.com/api-reference/products/product-families/remove-product-family-member /openapi/openapi-products.json delete /products/api/v1/families/{family_id}/members/{member_id} Remove a product from the family. The product itself is untouched. # Reorder product family members Source: https://docs.closient.com/api-reference/products/product-families/reorder-product-family-members /openapi/openapi-products.json post /products/api/v1/families/{family_id}/members/reorder Set the display order of every member at once. ``member_ids`` must list each member exactly once; nothing changes if it doesn't. The default variant always lists first regardless. # Update product family Source: https://docs.closient.com/api-reference/products/product-families/update-product-family /openapi/openapi-products.json patch /products/api/v1/families/{family_id} Partial update: only the keys in the body are applied. Requires CONTRIBUTE on the owning organization. 409 if the new ``external_ref`` is already used there. # Update product family member Source: https://docs.closient.com/api-reference/products/product-families/update-product-family-member /openapi/openapi-products.json patch /products/api/v1/families/{family_id}/members/{member_id} Change a member's variant axis, value, default flag or position. Setting ``is_default`` clears the previous default. # Add hierarchy link Source: https://docs.closient.com/api-reference/products/product-group/add-hierarchy-link /openapi/openapi-products.json post /products/api/v1/products/{gtin}/hierarchy Add a child product to this GTIN's packaging hierarchy. Only the parent product needs to belong to your organization — the child may be any product in the catalog, the same way a case can contain another company's items. # Read hierarchy link Source: https://docs.closient.com/api-reference/products/product-group/read-hierarchy-link /openapi/openapi-products.json get /products/api/v1/products/hierarchy/{hierarchy_id} Read a single parent-child hierarchy link by ID. # Remove hierarchy link Source: https://docs.closient.com/api-reference/products/product-group/remove-hierarchy-link /openapi/openapi-products.json delete /products/api/v1/products/hierarchy/{hierarchy_id} Remove a parent-child hierarchy link. Ownership is checked on the parent product. # Update hierarchy link Source: https://docs.closient.com/api-reference/products/product-group/update-hierarchy-link /openapi/openapi-products.json patch /products/api/v1/products/hierarchy/{hierarchy_id} Update the quantity on an existing hierarchy link. Ownership is checked on the parent product. # Create product link Source: https://docs.closient.com/api-reference/products/product-links/create-product-link /openapi/openapi-products.json post /products/api/v1/product-links Create a new CTA link for a product or brand. Returns 404 if the referenced product/brand doesn't exist or the caller lacks CONTRIBUTE permission on its owning organization; 422 if ``link_type`` isn't a recognised GS1 Web Vocabulary CURIE. # Delete product link Source: https://docs.closient.com/api-reference/products/product-links/delete-product-link /openapi/openapi-products.json delete /products/api/v1/product-links/{link_id} Permanently delete a product link. Returns ``{"success": true}`` on success, 404 if it doesn't exist or the caller lacks CONTRIBUTE permission. # Get product link Source: https://docs.closient.com/api-reference/products/product-links/get-product-link /openapi/openapi-products.json get /products/api/v1/product-links/{link_id} Fetch a single product link by id. Returns 404 if it doesn't exist or belongs to an organization the caller can't see. # List product links Source: https://docs.closient.com/api-reference/products/product-links/list-product-links /openapi/openapi-products.json get /products/api/v1/product-links List CTA links for a single product or brand. Unlike the request-body endpoints below, OpenAPI query parameters can't express a cross-field ``oneOf`` constraint (each parameter is its own independent schema entry) -- so instead of two independently-optional ``product_id``/``brand_id`` params (which let a schema-compliant request omit both or send both, 422'ing on a case the contract never admitted was possible), this endpoint takes one required ``owner_type`` + one required ``owner_id``. Caller must have VIEW permission on the owning organization. # Reorder product links Source: https://docs.closient.com/api-reference/products/product-links/reorder-product-links /openapi/openapi-products.json post /products/api/v1/product-links/reorder Atomically set ``order`` on every link belonging to one product or brand. Every link id referenced must belong to that product/brand, otherwise the whole call aborts with 404 and nothing is changed. # Update product link Source: https://docs.closient.com/api-reference/products/product-links/update-product-link /openapi/openapi-products.json put /products/api/v1/product-links/{link_id} Partial update — only the keys in the request body are applied. Returns 404 if the link doesn't exist or the caller lacks CONTRIBUTE permission; 422 on a validation failure (e.g. an unrecognised ``link_type``). # Attach a product image from a vendor-supplied URL Source: https://docs.closient.com/api-reference/products/products/attach-a-product-image-from-a-vendor-supplied-url /openapi/openapi-products.json post /products/api/v1/products/{gtin}/images/from-url Download `source_url` and attach it as this product's image. Unlike the retailer catalog import (which stores a URL discovered in a feed for later background ingestion), this call downloads synchronously and is meant for a URL a human already vetted — a private link a retailer's vendor-relations team handed back for a SKU whose catalog image was blocked. Idempotent: re-posting the same `source_url` for a product that already has it returns `skipped_existing` rather than re-downloading. The GTIN must name a product **your organization owns**; a product owned by another organization and one owned by nobody both return `404`, so the status code never reveals whether a GTIN exists in the catalog. Requires the `organization.contribute_organization` permission (OWNER/MANAGER/EDITOR member, or an editor-or-manager-role API key) on that organization — a read-only key gets `403`. # Check the brand domain's DNS TXT record Source: https://docs.closient.com/api-reference/products/products/check-the-brand-domains-dns-txt-record /openapi/openapi-products.json post /products/api/v1/products/{gtin}/verification/dns-check Looks up the TXT record named in the verification status response and compares it with the claim token. A match records a domain-control proof for the reviewer; it does not verify the claim. Requires the ``organization.contribute_organization`` permission on the membership owning the GTIN. Rate-limited per user and per organization; over the limit returns 429. # Check the brand domain's .well-known file Source: https://docs.closient.com/api-reference/products/products/check-the-brand-domains-well-known-file /openapi/openapi-products.json post /products/api/v1/products/{gtin}/verification/http-check Fetches the verification file URL named in the verification status response and compares its contents with the claim token. A match records a domain-control proof for the reviewer; it does not verify the claim. Returns 409 when HTTP verification is not enabled on this deployment; use the DNS check instead. Requires the ``organization.contribute_organization`` permission on the membership owning the GTIN. Rate-limited per user and per organization; over the limit returns 429. # Claim a product Source: https://docs.closient.com/api-reference/products/products/claim-a-product /openapi/openapi-products.json post /products/api/v1/products/{gtin}/claim Claim ownership of a GTIN on behalf of the authenticated user's business. The caller must be an OWNER or MANAGER of the business. A GTIN can only be claimed by one business; re-claiming by the same business is idempotent. Claim creation is rate limited per user and per organization (C-3840) — a flood returns 429. A claim outside the org's verified GS1 Company Prefix is created but not served by the resolver on the Free tier until verification completes (see the claim_verification_status field and the dashboard verification flow). # Create a document Source: https://docs.closient.com/api-reference/products/products/create-a-document /openapi/openapi-products.json post /products/api/v1/products/{gtin}/documents Attach a new document to a claimed product. Exactly one of ``file`` / ``external_url`` must be supplied. Requires the ``organization.contribute_organization`` permission on the membership owning the GTIN. # Create a product Source: https://docs.closient.com/api-reference/products/products/create-a-product /openapi/openapi-products.json post /products/api/v1/products Create a new product record and claim the GTIN for the authenticated user's organization. If the GTIN already exists, returns 409 — use POST /products/{gtin}/claim instead. # Create or update up to 1,000 products in one call Source: https://docs.closient.com/api-reference/products/products/create-or-update-up-to-1000-products-in-one-call /openapi/openapi-products.json post /products/api/v1/products/batch Merge a batch of normalised catalog rows into the shared catalog synchronously and return what happened to each row. It is the same GTIN-first merge a catalog import job runs — one product per GTIN across every retailer, one listing per retailer, per-field provenance, and a populated field displaced only by a strictly higher measured confidence — without opening a job, staging rows or polling. The call is idempotent: sending the same batch again reports every product `unchanged` and writes nothing. Each call is one transaction, so a batch lands whole or not at all. Ingredient labels are not parsed inside the request. A committed batch queues the labels it changed for background parsing and reports how many in `ingredients_queued`. `dry_run` defaults to **true**. Send `dry_run: false` to commit. # Delete a document Source: https://docs.closient.com/api-reference/products/products/delete-a-document /openapi/openapi-products.json delete /products/api/v1/products/{gtin}/documents/{document_id} Permanently delete a document. Requires the ``organization.contribute_organization`` permission on the membership owning the GTIN. # Delete (unclaim) a product Source: https://docs.closient.com/api-reference/products/products/delete-unclaim-a-product /openapi/openapi-products.json delete /products/api/v1/products/{gtin} Unclaim and deactivate a product GTIN. Sets brand to null and disables resolver. The caller must be an OWNER or MANAGER of the organization that owns the claim. Returns 409 while an ownership dispute on the GTIN is open. # Get a claim's verification status Source: https://docs.closient.com/api-reference/products/products/get-a-claims-verification-status /openapi/openapi-products.json get /products/api/v1/products/{gtin}/verification Where your organization's ownership claim on this GTIN stands: its verification tier, the evidence you have filed with each item's review state and expiry, how far the claim is from Verified under each promotion rule, and what to publish for the domain-control checks. Only the organization that owns the product can read it; any other GTIN is a 404. # Get catalog product detail Source: https://docs.closient.com/api-reference/products/products/get-catalog-product-detail /openapi/openapi-products.json get /products/api/v1/catalog/products/{gtin} Retrieve the full canonical record for a single GTIN, including dimensions, GPC codes, field-level confidence, usage instructions, brand product page URL, and AI-generated summary. Accepts GTIN-8/12/13/14 input; the GTIN is normalized to GTIN-14 before lookup. # Get product content Source: https://docs.closient.com/api-reference/products/products/get-product-content /openapi/openapi-products.json get /products/api/v1/products/{gtin}/content Retrieve every content sub-resource for a product in a single response: images, ingredients, nutrition facts, certifications, claims, recalls, circularity score, and FTL designation. Use this on resolver pages to avoid five round-trips; use the per-resource endpoints when only a slice is needed. # Get product detail Source: https://docs.closient.com/api-reference/products/products/get-product-detail /openapi/openapi-products.json get /products/api/v1/browse/products/{id} Retrieve product information by its URL-safe short ID. Returns 404 if no product matches the short ID. # Get product nutrition facts Source: https://docs.closient.com/api-reference/products/products/get-product-nutrition-facts /openapi/openapi-products.json get /products/api/v1/products/{gtin}/nutrition Retrieve the structured nutrition-facts panel for a product (serving size, servings per container, label format, per-nutrient rows). Returns 404 when no panel has been recorded for the product. # List and filter a product's documents Source: https://docs.closient.com/api-reference/products/products/list-and-filter-a-products-documents /openapi/openapi-products.json get /products/api/v1/products/{gtin}/documents Public catalogue read — any API key may list any product's active documents; results never vary by the caller's organization or subscription tier (C-4685). Filters combine with OR within a facet and AND across facets: ``?purpose=gs1:instructions&purpose=gs1:quickStartGuide&language=es`` returns documents matching *either* purpose, filed under Spanish. Language matching falls back to the primary subtag, so ``?language=es-MX`` matches a document filed under plain ``es``. Not paginated — a product's document count is small by construction (a handful of purposes, at most one pinned). ``is_active`` is always true here — an inactive document is never public (it 'disappears from the page, the API and the linkset', per the model's own contract); the brand's dashboard view is the place to see and manage inactive rows, not this endpoint. # List catalog products Source: https://docs.closient.com/api-reference/products/products/list-catalog-products /openapi/openapi-products.json get /products/api/v1/catalog/products List products (canonical GTIN records) with optional filtering by brand, category, claim status, country of origin, data source, or free-text search. Results are paginated; sort via `?ordering=product_name` or `?ordering=-created` (descending). # List claimed products Source: https://docs.closient.com/api-reference/products/products/list-claimed-products /openapi/openapi-products.json get /products/api/v1/products/ List all products claimed by a specific organization. Requires the organization_id query parameter. A caller outside the organization sees only the products whose ownership claim is not unverified, disputed or rejected. Supports `?metadata[key]=value` exact-match filtering (C-2734). Supports dietary-sensitivity filtering via `?avoid_sensitivity=histamine,sulfites&min_severity=MODERATE` — products flagged with ANY listed sensitivity at or above `min_severity` are excluded. # List product categories Source: https://docs.closient.com/api-reference/products/products/list-product-categories /openapi/openapi-products.json get /products/api/v1/browse/categories List all product categories from the Google Product Taxonomy. Used to populate category pickers and to look up the UUID needed for ``CatalogProductFilterSchema.category_id``. # List product certifications Source: https://docs.closient.com/api-reference/products/products/list-product-certifications /openapi/openapi-products.json get /products/api/v1/products/{gtin}/certifications Retrieve all certifications assigned to a product, including verification status, issuing body, and validity dates. Results are paginated. # List product claims Source: https://docs.closient.com/api-reference/products/products/list-product-claims /openapi/openapi-products.json get /products/api/v1/products/{gtin}/claims Retrieve all product claims (e.g. ``Non-GMO``, ``Vegan``) assigned to a product. Results are paginated. # List product hierarchy Source: https://docs.closient.com/api-reference/products/products/list-product-hierarchy /openapi/openapi-products.json get /products/api/v1/products/{gtin}/hierarchy Retrieve the GDSN packaging hierarchy for a product — e.g. case contains inner packs, inner pack contains eaches. Each row carries the parent/child GTINs, hierarchy level, and quantity. # List product images Source: https://docs.closient.com/api-reference/products/products/list-product-images /openapi/openapi-products.json get /products/api/v1/products/{gtin}/images Retrieve all images attached to a product, ordered by ``priority`` (with ``is_primary`` rendering first). Results are paginated. # List product ingredients Source: https://docs.closient.com/api-reference/products/products/list-product-ingredients /openapi/openapi-products.json get /products/api/v1/products/{gtin}/ingredients Retrieve the ingredient list for a product, ordered by predominance (label order). Each row carries the substance name, allergen flags, may-contain disclosure, percentage when declared, and origin. # List product recalls Source: https://docs.closient.com/api-reference/products/products/list-product-recalls /openapi/openapi-products.json get /products/api/v1/products/{gtin}/recalls Retrieve recall records linked to a product across FDA, FSIS, CPSC, and Health Canada. Filter by ``status``, ``severity``, or ``source_system``; sort by ``recall_date`` or ``severity``. # List products Source: https://docs.closient.com/api-reference/products/products/list-products /openapi/openapi-products.json get /products/api/v1/browse/products List products in the catalog, optionally filtered by brand or active status. Results are paginated; page through with `?page=2&page_size=50`. Sorting via `?ordering=product_name` or `?ordering=-created` (descending). # Look up a product Source: https://docs.closient.com/api-reference/products/products/look-up-a-product /openapi/openapi-products.json get /products/api/v1/products/{gtin} Retrieve canonical product data for a GTIN. Accepts GTIN-8, GTIN-12, GTIN-13, or GTIN-14 (with or without hyphens/spaces). The GTIN is normalized to GTIN-14 before lookup. A product whose ownership claim is unverified, disputed or rejected returns 404 unless the caller belongs to the organization that owns it. # Read a single document Source: https://docs.closient.com/api-reference/products/products/read-a-single-document /openapi/openapi-products.json get /products/api/v1/products/{gtin}/documents/{document_id} Public catalogue read of one active document belonging to the given GTIN. # Read per-field provenance Source: https://docs.closient.com/api-reference/products/products/read-per-field-provenance /openapi/openapi-products.json get /products/api/v1/products/{gtin}/provenance Which data source supplied each field's current value, with its confidence and last-updated time. Owner-scoped (C-5983): provenance names which retailer or feed supplied a value, so only a member of the organization that owns the product's brand may read it; anyone else gets the same 404 as a GTIN that does not exist. Fields with confidence or timestamps but no recorded source are reported as `unattributed`, which is a different state from `attributed` to a source. # Read product ingredient Source: https://docs.closient.com/api-reference/products/products/read-product-ingredient /openapi/openapi-products.json get /products/api/v1/products/{gtin}/ingredients/{ingredient_id} Read a single ingredient row by ID. # Redirect to the document's current file location Source: https://docs.closient.com/api-reference/products/products/redirect-to-the-documents-current-file-location /openapi/openapi-products.json get /products/api/v1/products/{gtin}/documents/{document_id}/download Permanent redirect endpoint — safe to store, publish in a resolver linkset, or hand to an AI agent. Each hit issues a fresh presigned URL (or the brand's own external link) and redirects to it, so the link a caller stores never expires even though the object access underneath still does. # Report a suspicious GTIN ownership claim Source: https://docs.closient.com/api-reference/products/products/report-a-suspicious-gtin-ownership-claim /openapi/openapi-products.json post /products/api/v1/products/{gtin}/report-claim File a report against the current ownership claim on a GTIN. No authentication required — real brand owners may not yet have a Closient account. Reports are reviewed by Closient staff; reaching a threshold of distinct reporters flags the product for admin triage. Rate limited to 3 reports per IP per GTIN per 24 hours. # Submit claim evidence for review Source: https://docs.closient.com/api-reference/products/products/submit-claim-evidence-for-review /openapi/openapi-products.json post /products/api/v1/products/{gtin}/verification/evidence File a GS1 certificate, a Verified by GS1 registry record, an invoice or other proof of ownership. It is stored as pending and counts toward Verified only after staff approve it; this endpoint never changes the claim's status. Attach a file as base64 or describe the evidence in notes; at least one is required. Requires the ``organization.contribute_organization`` permission on the membership owning the GTIN. Rate-limited per user and per organization; over the limit returns 429. # Unclaim a product Source: https://docs.closient.com/api-reference/products/products/unclaim-a-product /openapi/openapi-products.json post /products/api/v1/products/{gtin}/unclaim Release ownership of a previously claimed GTIN. The caller must be an OWNER or MANAGER of the organization that owns the claim. Returns 409 while an ownership dispute on the GTIN is open. # Update a document's facets and metadata Source: https://docs.closient.com/api-reference/products/products/update-a-documents-facets-and-metadata /openapi/openapi-products.json patch /products/api/v1/products/{gtin}/documents/{document_id} Only supplied fields change. To replace the underlying file or link, create a new document and delete the old one — a JSON body has no way to carry a file upload, and django-ninja does not support file uploads on PATCH without a project-wide middleware change. Requires the ``organization.contribute_organization`` permission on the membership owning the GTIN. # Update a product Source: https://docs.closient.com/api-reference/products/products/update-a-product /openapi/openapi-products.json patch /products/api/v1/products/{gtin} Update editable fields on a claimed product. The caller must be an OWNER, MANAGER, or EDITOR of the organization that owns the GTIN. Only explicitly provided (non-null) fields are updated. # Generate a batch of QR codes asynchronously Source: https://docs.closient.com/api-reference/products/qr/generate-a-batch-of-qr-codes-asynchronously /openapi/openapi-products.json post /products/api/v1/qr/bulk/ Kick off a Celery task that renders one QR per item, packages them into a ZIP bundle, and uploads the bundle to object storage. Returns a task ID immediately (HTTP 202). Poll ``GET /qr/bulk/{task_id}/`` for status and the temporary download URL. Hard cap: 5000 items per request. **HRI (C-5840)**: ``hri`` and ``hri_preset`` resolve once per job when it is accepted, and composing them requires ``xdim_mm``. The first item is rendered at accept time, so a configuration that cannot render (legibility, placement, format) returns 422 instead of a job that fails. The organization's default preset applies only when ``xdim_mm`` is set and its placement is legal for QR; otherwise the job renders bare QR codes. A preset with HRI disabled always renders bare codes. # Generate a single QR code Source: https://docs.closient.com/api-reference/products/qr/generate-a-single-qr-code /openapi/openapi-products.json post /products/api/v1/qr/ Render a QR code for a GTIN with optional GS1 Application Identifiers (lot, serial, expiry). Returns the image bytes directly with the appropriate content-type (``image/svg+xml``, ``image/png``, ``application/pdf``, or ``application/postscript``). Single QR generation is synchronous because rendering is fast (<50ms) and cached. For batches, use ``POST /qr/bulk/``. **HRI (C-4395)**: pass ``hri`` (inline options), ``hri_preset`` (a saved preset name), or neither — in which case the calling organization's default preset applies automatically, if one is set. Composing HRI requires ``xdim_mm`` and does not support ``format='tif'``; ``format='png'`` additionally requires ``dpmm`` to map to 300 or 600 dpi. Those requirements return 422 for ``hri`` and ``hri_preset``; the organization's default is skipped instead when the request can't meet them, or when its placement isn't legal for QR. A disabled preset renders the bare symbol. Omitting both and having no org default renders the bare symbol exactly as before this field existed. **Caching**: responses are deterministic given the request body **when ``hri`` is used** (or neither ``hri`` nor ``hri_preset`` is set and the org has no default preset) — cached ``public, max-age=2592000`` (30 days) with a strong ``ETag``. When a *named or default preset* supplies the HRI configuration, the response additionally depends on mutable per-organization state, so it is cached ``private`` instead. Clients sending ``If-None-Match`` with a matching ETag get ``304 Not Modified`` either way. # Poll a bulk QR generation task Source: https://docs.closient.com/api-reference/products/qr/poll-a-bulk-qr-generation-task /openapi/openapi-products.json get /products/api/v1/qr/bulk/{task_id}/ Returns the status of a previously queued bulk QR job. When the job is complete, the response includes a temporary signed download URL for the ZIP bundle and an ``expires_at`` timestamp. The endpoint is rate limited at the global API key tier. # Create recipe Source: https://docs.closient.com/api-reference/products/recipes/create-recipe /openapi/openapi-products.json post /products/api/v1/recipes Create a recipe and attach it to the listed products. Requires CONTRIBUTE on the organization. 404 if any GTIN isn't a product the organization owns; nothing is created. # Delete recipe Source: https://docs.closient.com/api-reference/products/recipes/delete-recipe /openapi/openapi-products.json delete /products/api/v1/recipes/{recipe_id} Delete a recipe. The products it was attached to are untouched. Requires CONTRIBUTE on the owning organization. # Get recipe Source: https://docs.closient.com/api-reference/products/recipes/get-recipe /openapi/openapi-products.json get /products/api/v1/recipes/{recipe_id} Fetch one recipe with its products. 404 if it doesn't exist or the caller can't see it. # List recipes Source: https://docs.closient.com/api-reference/products/recipes/list-recipes /openapi/openapi-products.json get /products/api/v1/recipes List recipes across the organizations the caller can see, ordered by title. Filter by organization, attached product GTIN or difficulty. Paginated. # Update recipe Source: https://docs.closient.com/api-reference/products/recipes/update-recipe /openapi/openapi-products.json patch /products/api/v1/recipes/{recipe_id} Partial update: only the keys in the body are applied. ``gtins``, when present, replaces the attached products. Requires CONTRIBUTE on the owning organization. # Create replacement part Source: https://docs.closient.com/api-reference/products/replacement-parts/create-replacement-part /openapi/openapi-products.json post /products/api/v1/replacement-parts List one of the organization's products as a replacement part of another. Requires CONTRIBUTE on the organization. 404 if either GTIN isn't a product the organization owns; 409 if the pair already exists; 422 if a product is made its own part. Nothing is created on error. # Delete replacement part Source: https://docs.closient.com/api-reference/products/replacement-parts/delete-replacement-part /openapi/openapi-products.json delete /products/api/v1/replacement-parts/{part_id} Remove a replacement part. Neither product is touched. Requires CONTRIBUTE on the owning organization. # Get replacement part Source: https://docs.closient.com/api-reference/products/replacement-parts/get-replacement-part /openapi/openapi-products.json get /products/api/v1/replacement-parts/{part_id} Fetch one replacement part. 404 if it doesn't exist or the caller can't see it. # List replacement parts Source: https://docs.closient.com/api-reference/products/replacement-parts/list-replacement-parts /openapi/openapi-products.json get /products/api/v1/replacement-parts List replacement parts across the organizations the caller can see, ordered by product GTIN. Filter by organization or by the GTIN of the product the part belongs to. Paginated. # Update replacement part Source: https://docs.closient.com/api-reference/products/replacement-parts/update-replacement-part /openapi/openapi-products.json patch /products/api/v1/replacement-parts/{part_id} Point the part at a different product of the same organization. The product the part belongs to cannot change; delete and recreate to move it. Requires CONTRIBUTE on the owning organization. # Open a retailer catalog import job Source: https://docs.closient.com/api-reference/products/retailer-catalog-import/open-a-retailer-catalog-import-job /openapi/openapi-products.json post /products/api/v1/import/retailer-catalog/jobs Open a job for one catalog source and return its id. The job starts in `staging`: stage rows with `POST .../jobs/{job_id}/rows`, then run it with `POST .../jobs/{job_id}/start`. The run merges rows into the shared catalog GTIN-first — one product per GTIN across every retailer, one listing per retailer that carries it, and per-field provenance recording which retailer supplied what. A populated field is displaced only by a strictly higher measured confidence, which is what makes a rerun of the same feed inert and keeps the two feeds' disagreements resolved the same way whatever order they run in. `dry_run` defaults to **true**. Send `dry_run: false` to commit. # Poll a catalog import job Source: https://docs.closient.com/api-reference/products/retailer-catalog-import/poll-a-catalog-import-job /openapi/openapi-products.json get /products/api/v1/import/retailer-catalog/jobs/{job_id} Return the job's status and, once it completes, the engine's full census: products created and enriched, listings created and updated, the cross-retailer overlap, per-field decision counts, brand and ingredient figures, and the quarantined rows with the reason each was refused. On a dry run the quarantined rows come from the run's in-memory record, because the quarantine table rolls back with each batch; on a committed run they are read back from the table and additionally carry `attempt_count` and `resolved`. Polling a job belonging to another organization returns 404 rather than 403, so job ids cannot be enumerated by status code. # Stage rows onto a catalog import job Source: https://docs.closient.com/api-reference/products/retailer-catalog-import/stage-rows-onto-a-catalog-import-job /openapi/openapi-products.json post /products/api/v1/import/retailer-catalog/jobs/{job_id}/rows Append a batch of normalised rows to a job that is still `staging`. Call it repeatedly to stream a whole feed: at most 1,000 rows per call, 400,000 per job. Rows are staged to storage and consumed lazily by the run, one batch at a time, so a quarter-million-row feed never materialises in memory. Returns 409 when the job has already started — rows can only be added before the run, and silently ignoring a late batch would under-import a feed while reporting success. # Start or resume a catalog import job Source: https://docs.closient.com/api-reference/products/retailer-catalog-import/start-or-resume-a-catalog-import-job /openapi/openapi-products.json post /products/api/v1/import/retailer-catalog/jobs/{job_id}/start Queue the staged rows for the server-side merge and return 202 with the job in `queued`. Poll `GET .../jobs/{job_id}` for progress — `processed_rows` advances every `batch_size` rows, `chunks_done` and `result` every `chunk_rows` — and for the outcome. The merge runs as a sequential chain of chunk tasks of `chunk_rows` rows each, so no single worker task approaches its time limit. **Calling this on a `failed` job resumes it where it stopped**: every finished chunk keeps its rows and its share of the census, and so does every finished batch of the chunk that was in flight, so an interrupted run costs at most `batch_size` rows of repeated work rather than a chunk or a feed. Returns 409 when the job is `queued`, `running` or `completed` — the first two already have a chain and a second would race it on one cursor, and the third already has its census — or when no rows are staged, because an empty run would report zeros that read like a clean import. # Create custom URL Source: https://docs.closient.com/api-reference/resolver/custom-urls/create-custom-url /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/custom-urls Create a custom-URL catalog entry for the organization. Returns ``409`` if the org already has an entry with the same URL (URLs are unique per org). Returns ``404`` if the org doesn't exist or the caller lacks CONTRIBUTE permission. # Delete custom URL Source: https://docs.closient.com/api-reference/resolver/custom-urls/delete-custom-url /openapi/openapi-resolver.json delete /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id} Permanently delete a custom-URL catalog entry. Returns ``{"success": true}`` on success. Returns ``409`` if a ``CUSTOM_URL`` resolution rule still references the entry — deleting it would leave that rule without a destination — so re-point or remove the referencing rules first. Returns ``404`` if the entry doesn't exist or the caller lacks CONTRIBUTE permission. # Get custom URL Source: https://docs.closient.com/api-reference/resolver/custom-urls/get-custom-url /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id} Return one custom-URL catalog entry. Requires VIEW permission. Returns ``404`` if the entry doesn't exist, belongs to another org, or the caller lacks VIEW permission. # List custom URLs Source: https://docs.closient.com/api-reference/resolver/custom-urls/list-custom-urls /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/custom-urls Return every custom-URL catalog entry owned by the organization, ordered by URL. Results use the standard ``{data, pagination}`` envelope. Requires VIEW permission. # Retry custom-URL health check Source: https://docs.closient.com/api-reference/resolver/custom-urls/retry-custom-url-health-check /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id}/retry Run an on-demand health check for a single custom-URL catalog entry and return the refreshed entry with its updated ``status`` and ``last_checked_at``. The check is synchronous (the caller gets the fresh result inline). Returns ``404`` if the entry doesn't exist, belongs to another org, or the caller lacks CONTRIBUTE permission. # Update custom URL Source: https://docs.closient.com/api-reference/resolver/custom-urls/update-custom-url /openapi/openapi-resolver.json put /resolver/api/v1/organizations/{organization_id}/custom-urls/{custom_url_id} Update a custom-URL catalog entry's address. Editing the URL resets its health status to ``UNKNOWN`` (the new address hasn't been checked). Returns ``409`` on a per-org URL collision, ``404`` if the entry doesn't exist or the caller lacks CONTRIBUTE permission. # Compose a GS1 Digital Link URI from Application Identifiers Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/compose-a-gs1-digital-link-uri-from-application-identifiers /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/build Build a spec-ordered Digital Link URI from a primary key plus optional qualifiers and data attributes, and grade the result. Qualifiers and attributes are **repeated** query parameters in `code:value` form — `?qualifier=10:LOT1&qualifier=21:SN1&attribute=17:261231` — which keeps the whole request in the URL and therefore cacheable. A token missing its `:` is reported as an `error` issue rather than dropped, so a URI is never silently composed without data you asked for. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Compress Application Identifiers into a GS1 Digital Link path segment Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/compress-application-identifiers-into-a-gs1-digital-link-path-segment /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/compress Bit-pack a primary key plus optional qualifiers/attributes into the single opaque base64url path segment defined by GS1's Digital Link compression scheme — the exact inverse of `/decompress`. Takes the same `primary_ai` / `primary_value` / repeated `qualifier=code:value` / `attribute=code:value` shape as `/build`, so the same query string composes both the uncompressed and compressed forms of an identifier set. An AI set that cannot be compressed (unknown AI code, malformed value, no primary identifier) is a `200` with `is_valid: false` and the reason — the same triage contract as `/validate` and `/decompress`. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Decode a GS1-compressed Digital Link path segment Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/decode-a-gs1-compressed-digital-link-path-segment /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/decompress Decode the compressed form of a Digital Link (the single opaque path segment produced by GS1's compression scheme) back into its Application Identifiers, plus the equivalent uncompressed path. An undecodable segment is a `200` with `is_valid: false` and the reason — the same triage contract as `/validate`. The inverse direction is `/compress`. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Parse a GS1 Digital Link URI into its Application Identifiers Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/parse-a-gs1-digital-link-uri-into-its-application-identifiers /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/parse Decompose a URI into its primary key, qualifiers and data attributes, plus a flat `ai_map` for callers that just want a lookup table. Backed by the same engine as `/validate`; use this when you want the structure and `/validate` when you want the reasons. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Validate a GS1 Digital Link URI against the specification Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/validate-a-gs1-digital-link-uri-against-the-specification /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/validate Grade a candidate URI for GS1 Digital Link conformance and return every issue found, each with a stable `code`, a `severity`, and a human-readable `message`. A non-conformant URI is a `200` with `is_conformant: false` — that is the *answer* to a validation question, not a failed request. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Verify a GS1 key's check digit and identify its prefix Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/verify-a-gs1-keys-check-digit-and-identify-its-prefix /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/analyze Check the GS1 mod-10 check digit on any numeric GS1 key (GTIN-8/12/13/14, GLN, SSCC) and — for GTIN-shaped keys — report which GS1 Member Organisation or numbering scheme the prefix belongs to. `expected_check_digit` is returned even when the supplied digit is wrong, because the useful answer to a failed check is which digit it should have been. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Which Application Identifiers belong on the pack, per scan context Source: https://docs.closient.com/api-reference/resolver/gs1-primitives-public/which-application-identifiers-belong-on-the-pack-per-scan-context /openapi/openapi-resolver.json get /resolver/api/v1/public/gs1/placement Anything that must produce a decision without a network connection goes on the pack. Everything else is a pointer. Each row classifies one AI for one scan context (consumer scan, retail POS, supply chain AIDC) as `on_pack_required`, `on_pack_recommended` or `server_side_eligible`, with a rationale. Supply chain AIDC is its own context because a receiving dock running a recall check on a pallet with no signal needs the lot on the pack, where a consumer scan does not. Placement is advisory: the URI grammar decides where an AI sits and which qualifiers are mandatory, and following a recommendation never yields a non-conformant URI. AIs the standard forbids in a Digital Link are omitted. **Keyless** — no API key, no signup. IP-throttled at 300 requests/minute and cached at the edge for 24h, so repeat calls for the same input do not reach origin. Over-rate callers get a `429` with a `Retry-After` header and a `retry_after` field — it never silently degrades or returns a wrong answer under load. # Custom-hostname onboarding nudge Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/custom-hostname-onboarding-nudge /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/custom-hostname-nudge Whether to recommend a custom hostname to this organization (true once it has minted a long-lived asset identifier and has no verified hostname yet), with the copy to show. Requires VIEW permission. # Disable a custom resolver hostname Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/disable-a-custom-resolver-hostname /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/custom-hostnames/disable Stop routing a hostname to the organization (reversible — re-verify to re-enable). Returns ``422`` if the hostname isn't the org's. Requires CHANGE permission. # Get a custom resolver hostname Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/get-a-custom-resolver-hostname /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/custom-hostnames/{hostname} One custom hostname registered to the organization, with its status and the DNS records to publish. The hostname itself is the identifier: it is globally unique and every action above addresses it that way. Returns ``404`` when the organization has no such hostname, whether it does not exist or another organization holds it. Requires VIEW permission. # List custom resolver hostnames Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/list-custom-resolver-hostnames /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/custom-hostnames All custom hostnames registered to the organization, any status, with the DNS records to publish for each. Requires VIEW permission. # Register a custom resolver hostname Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/register-a-custom-resolver-hostname /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/custom-hostnames Register a customer-owned hostname in PENDING state and return the DNS records to publish. Re-requesting a not-yet-verified hostname the org already owns refreshes its challenge token. Returns ``422`` when the hostname is Closient-owned or already claimed by another organization. Requires CHANGE permission. # Set a custom resolver hostname's unresolved-scan fallback policy Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/set-a-custom-resolver-hostnames-unresolved-scan-fallback-policy /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/custom-hostnames/fallback-policy Set what a dead-end scan on this hostname does: hosted_404 (default, a Closient-hosted brand-themed 404) or redirect (a 307 to a dashboard-configured destination). Returns 422 if the hostname isn't the org's, or if mode is redirect and redirect_url isn't a well-formed absolute https URL. Requires CHANGE permission — this is the only write path for the policy, and by construction the destination can never be sourced from anything but this request's own redirect_url field (see apps.resolver.services.fallback_policy). Reason codes, both modes and the csi_ diagnostic parameters a redirect carries are explained at https://docs.closient.com/user-guides/resolution-fallback. # Verify a custom resolver hostname Source: https://docs.closient.com/api-reference/resolver/resolver-custom-hostnames/verify-a-custom-resolver-hostname /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/custom-hostnames/verify Check the published CNAME challenge record and mark the hostname VERIFIED on a match. Safe to call repeatedly while DNS propagates — a miss records ``last_error`` and leaves the hostname PENDING. Returns ``422`` if the hostname isn't registered to this organization. Requires CHANGE permission. # Create resolution rule Source: https://docs.closient.com/api-reference/resolver/resolver/create-resolution-rule /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/resolution-rules Create a new resolution rule for the organization. Returns ``422`` when ``destination_type == 'CUSTOM_URL'`` but ``custom_url`` is empty, or when ``annual_start_mmdd`` / ``annual_end_mmdd`` are not in ``MM-DD`` format. Returns ``404`` if the organization doesn't exist or is one the caller cannot see, and ``403`` if the caller can see it but lacks CHANGE permission (a read-only key). A ``product_id`` naming a product this organization does not own also returns ``404`` — indistinguishable from a product that does not exist. The newly-created rule's ``short_id`` is the canonical handle for subsequent ``GET``/``PUT``/``DELETE`` calls. # Delete resolution rule Source: https://docs.closient.com/api-reference/resolver/resolver/delete-resolution-rule /openapi/openapi-resolver.json delete /resolver/api/v1/resolution-rules/{rule_id} Permanently delete a resolution rule. Returns ``{"success": true}`` on success, ``404`` if the rule doesn't exist or belongs to an organization the caller cannot see, and ``403`` if the caller can see it but lacks CHANGE permission (a read-only key). Cascades the rule's ``CustomURLHealth`` row but does not affect audit-log entries. # Get resolution rule Source: https://docs.closient.com/api-reference/resolver/resolver/get-resolution-rule /openapi/openapi-resolver.json get /resolver/api/v1/resolution-rules/{rule_id} Fetch a single resolution rule by its ``short_id``. Returns ``404`` if the rule does not exist or belongs to an organization the caller cannot see. # Get resolution rule summary Source: https://docs.closient.com/api-reference/resolver/resolver/get-resolution-rule-summary /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/resolution-rules/summary Return aggregate counts of the organization's resolution rules: the total, the active/inactive split (on the ``enabled`` flag), and a per-destination-type breakdown. Powers the Resolver Overview 'Rules' stat card. Scoped to the organization in the path; the caller must have VIEW permission. # Get the default resolution rule Source: https://docs.closient.com/api-reference/resolver/resolver/get-the-default-resolution-rule /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/resolution-rules/default Return the implicit, system-managed default rule — the baseline fallback the resolver applies when no user-created rule matches a scan (a redirect to the brand's Closient-hosted page, C-3956). The default rule is a sentinel, not a stored row: it has no ``id`` and cannot be created, edited, deleted, or reordered. Clients render it at the bottom of the rules list, below all user rules, and use ``system: true`` to flag it as non-editable. Returns ``404`` if the organization doesn't exist or the caller lacks VIEW permission. The default is the same for every organization, so this endpoint is unfiltered. # List resolution rules Source: https://docs.closient.com/api-reference/resolver/resolver/list-resolution-rules /openapi/openapi-resolver.json get /resolver/api/v1/organizations/{organization_id}/resolution-rules Return every resolution rule owned by the organization, optionally filtered by scope and enabled status. Results are paginated with the standard ``{data, pagination}`` envelope (max ``page_size`` 100, default 25) and ordered by ``(scope_type, scope entity, order_index)`` — the same order the resolver evaluator uses. # Reorder resolution rules Source: https://docs.closient.com/api-reference/resolver/resolver/reorder-resolution-rules /openapi/openapi-resolver.json post /resolver/api/v1/organizations/{organization_id}/resolution-rules/reorder Atomically update ``order_index`` on a contiguous block of rules in a single transaction. Every rule referenced by ``short_id`` must belong to the URL's organization, otherwise the whole call aborts with ``404`` and no rules are mutated. Returns ``403`` when the caller can see the organization but lacks CHANGE permission (a read-only key). Returns the affected rules in their new evaluation order. # Update resolution rule Source: https://docs.closient.com/api-reference/resolver/resolver/update-resolution-rule /openapi/openapi-resolver.json put /resolver/api/v1/resolution-rules/{rule_id} Partial update. Only the keys in the request body are applied; omitted keys are left unchanged. Sending ``null`` for a nullable field clears it. Re-checks the custom-url-required invariant against the merged (new + existing) state — returns ``422`` if the result would have ``destination_type == 'CUSTOM_URL'`` with an empty ``custom_url``. Returns ``404`` if the rule belongs to an organization the caller cannot see, or if ``product_id`` names a product that organization does not own, and ``403`` if the caller can see the rule but lacks CHANGE permission (a read-only key). # Verify a serialized GTIN (gs1:verificationService) Source: https://docs.closient.com/api-reference/resolver/verification/verify-a-serialized-gtin-gs1:verificationservice /openapi/openapi-resolver.json get /resolver/api/v1/verify/01/{gtin}/21/{serial_number} Verify a serialized GTIN scan. Tiered response: * **Unauthenticated** — minimal payload (`verificationStatus`, `gtin`, `serialNumber`, `verifiedAt`, `recommendation`). Throttled at 60 requests / hour per IP. Unknown serials return the same shape as known-authentic to prevent enumeration. * **Authenticated** (X-API-Key) — full payload including `scanHistory` counters and `anomalies` array. No anonymous rate limit. Path parameters mirror GS1 Digital Link AI ordering: `01` (GTIN) + `21` (serial). Returns `422` for malformed GTIN or serial. **Experimental (v1):** This linkType response shape is published as a v1 experimental contract. The auth-tier split, response fields, and `verificationStatus` enum may change before the first external integration freezes the shape. # Create in-store offer promotion Source: https://docs.closient.com/api-reference/retailers/in-store-offer-promotions/create-in-store-offer-promotion /openapi/openapi-retailers.json post /retailers/api/v1/in-store-promotions Create a time-windowed promotional price on an in-store offer. ``end_date`` must be strictly after ``start_date``; the database enforces this with a check constraint. Currency is inherited from the offer's parent ``PhysicalStore`` — there is no per-promotion currency override. Returns ``404`` (rather than ``403``) when the caller lacks ``MANAGE_OFFERS`` to avoid leaking offer existence. # Delete in-store offer promotion Source: https://docs.closient.com/api-reference/retailers/in-store-offer-promotions/delete-in-store-offer-promotion /openapi/openapi-retailers.json delete /retailers/api/v1/in-store-promotions/{promotion_id} Permanently delete an in-store offer promotion. Use the update endpoint to soft-deactivate (``is_active=false``) if you want to preserve the historical pricing row. Returns ``404`` when the promotion does not exist or the caller lacks ``MANAGE_OFFERS`` on the parent offer's owning organization. # Get in-store promotion Source: https://docs.closient.com/api-reference/retailers/in-store-offer-promotions/get-in-store-promotion /openapi/openapi-retailers.json get /retailers/api/v1/in-store-promotions/{promotion_id} Retrieve a single in-store offer promotion by UUID. Returns ``404`` when the promotion does not exist or the caller lacks the ``VIEW`` permission on the parent offer's owning organization. # List in-store offer promotions Source: https://docs.closient.com/api-reference/retailers/in-store-offer-promotions/list-in-store-offer-promotions /openapi/openapi-retailers.json get /retailers/api/v1/in-store-offers/{offer_id}/promotions Return every promotion (active and inactive) attached to the given in-store offer. Returns ``404`` when the offer does not exist or the caller lacks the ``VIEW`` permission on the offer's owning organization. ``404`` is used (rather than ``403``) to avoid leaking offer existence. # Update in-store offer promotion Source: https://docs.closient.com/api-reference/retailers/in-store-offer-promotions/update-in-store-offer-promotion /openapi/openapi-retailers.json put /retailers/api/v1/in-store-promotions/{promotion_id} Partially update an in-store offer promotion. Omitted keys preserve existing values; ``metadata`` follows Stripe semantics (empty string deletes a key, ``null`` clears all). ``offer_id`` is not updatable — delete and recreate to move a promotion. Returns ``404`` when the promotion does not exist or the caller lacks ``MANAGE_OFFERS`` on the parent offer's owning organization. # Create in-store offer Source: https://docs.closient.com/api-reference/retailers/in-store-offers/create-in-store-offer /openapi/openapi-retailers.json post /retailers/api/v1/organizations/{organization_id}/in-store-offers Create a new in-store offer for the given organization. The ``(product_id, physical_store_id, sku)`` triple must be unique — sending a duplicate returns ``422``. Returns ``404`` when the organization, product, or store does not exist; ``404`` is also used (rather than ``403``) when the caller lacks ``MANAGE_OFFERS`` to avoid leaking organization existence. Offer creation is rate-limited per organization; past the limit this returns ``429`` with ``retry_after``. An offer on a product another organization owns is stored but hidden from public surfaces unless Closient verifies it. # Delete in-store offer Source: https://docs.closient.com/api-reference/retailers/in-store-offers/delete-in-store-offer /openapi/openapi-retailers.json delete /retailers/api/v1/in-store-offers/{offer_id} Permanently delete an in-store offer. Promotions attached to the offer are cascade-deleted. The organization that submitted the offer may delete it, and so may the organization that owns its product when another organization submitted it unverified. Returns ``404`` when the offer does not exist or the caller lacks ``MANAGE_OFFERS`` on both. # Get in-store offer Source: https://docs.closient.com/api-reference/retailers/in-store-offers/get-in-store-offer /openapi/openapi-retailers.json get /retailers/api/v1/in-store-offers/{offer_id} Retrieve a single in-store offer by UUID. Readable by the organization that submitted it and, for another organization's unverified offer, by the organization that owns its product. Returns ``404`` when the offer does not exist or the caller lacks ``VIEW`` on both. # List in-store offers Source: https://docs.closient.com/api-reference/retailers/in-store-offers/list-in-store-offers /openapi/openapi-retailers.json get /retailers/api/v1/organizations/{organization_id}/in-store-offers Return in-store (physical-store) offers for the given organization — by default the ones it submitted; with ``scope=on_products``, also the unverified offers other organizations submitted on products it owns. Results are paginated with the standard ``{data, pagination}`` envelope; default ``page_size`` is 25, max is 100. Returns ``404`` when the organization does not exist or the caller lacks the ``VIEW`` permission on it. # Update in-store offer Source: https://docs.closient.com/api-reference/retailers/in-store-offers/update-in-store-offer /openapi/openapi-retailers.json put /retailers/api/v1/in-store-offers/{offer_id} Partially update an in-store offer. Omitted keys preserve existing values; ``metadata`` follows Stripe semantics (empty string deletes a key, ``null`` clears all). Returns ``404`` when the offer does not exist or the caller lacks ``MANAGE_OFFERS`` on the offer's owning organization. # Create online offer promotion Source: https://docs.closient.com/api-reference/retailers/online-offer-promotions/create-online-offer-promotion /openapi/openapi-retailers.json post /retailers/api/v1/online-promotions Create a time-windowed promotional price on an online offer. ``end_date`` must be strictly after ``start_date``; the database enforces this with a check constraint. Currency is inherited from the offer's parent ``OnlineStore`` — there is no per-promotion currency override. Returns ``404`` (rather than ``403``) when the caller lacks ``MANAGE_OFFERS`` to avoid leaking offer existence. # Delete online offer promotion Source: https://docs.closient.com/api-reference/retailers/online-offer-promotions/delete-online-offer-promotion /openapi/openapi-retailers.json delete /retailers/api/v1/online-promotions/{promotion_id} Permanently delete an online offer promotion. Use the update endpoint to soft-deactivate (``is_active=false``) if you want to preserve the historical pricing row. Returns ``404`` when the promotion does not exist or the caller lacks ``MANAGE_OFFERS`` on the parent offer's owning organization. # Get online promotion Source: https://docs.closient.com/api-reference/retailers/online-offer-promotions/get-online-promotion /openapi/openapi-retailers.json get /retailers/api/v1/online-promotions/{promotion_id} Retrieve a single online offer promotion by UUID. Returns ``404`` when the promotion does not exist or the caller lacks the ``VIEW`` permission on the parent offer's owning organization. # List online offer promotions Source: https://docs.closient.com/api-reference/retailers/online-offer-promotions/list-online-offer-promotions /openapi/openapi-retailers.json get /retailers/api/v1/online-offers/{offer_id}/promotions Return every promotion (active and inactive) attached to the given online offer. Returns ``404`` when the offer does not exist or the caller lacks the ``VIEW`` permission on the offer's owning organization. ``404`` is used (rather than ``403``) to avoid leaking offer existence. # Update online offer promotion Source: https://docs.closient.com/api-reference/retailers/online-offer-promotions/update-online-offer-promotion /openapi/openapi-retailers.json put /retailers/api/v1/online-promotions/{promotion_id} Partially update an online offer promotion. Omitted keys preserve existing values; ``metadata`` follows Stripe semantics (empty string deletes a key, ``null`` clears all). ``offer_id`` is not updatable — delete and recreate to move a promotion. Returns ``404`` when the promotion does not exist or the caller lacks ``MANAGE_OFFERS`` on the parent offer's owning organization. # Create online offer Source: https://docs.closient.com/api-reference/retailers/online-offers/create-online-offer /openapi/openapi-retailers.json post /retailers/api/v1/organizations/{organization_id}/online-offers Create a new online offer for the given organization. The ``(product_id, online_store_id, sku)`` triple must be unique — sending a duplicate returns ``422``. Returns ``404`` when the organization, product, or store does not exist; ``404`` is also used (rather than ``403``) when the caller lacks ``MANAGE_OFFERS`` to avoid leaking organization existence. Offer creation is rate-limited per organization; past the limit this returns ``429`` with ``retry_after``. An offer on a product another organization owns is stored but hidden from public surfaces unless Closient verifies it. # Delete online offer Source: https://docs.closient.com/api-reference/retailers/online-offers/delete-online-offer /openapi/openapi-retailers.json delete /retailers/api/v1/online-offers/{offer_id} Permanently delete an online offer. Promotions attached to the offer are cascade-deleted. The organization that submitted the offer may delete it, and so may the organization that owns its product when another organization submitted it unverified. Returns ``404`` when the offer does not exist or the caller lacks ``MANAGE_OFFERS`` on both. # Get online offer Source: https://docs.closient.com/api-reference/retailers/online-offers/get-online-offer /openapi/openapi-retailers.json get /retailers/api/v1/online-offers/{offer_id} Retrieve a single online offer by UUID. Readable by the organization that submitted it and, for another organization's unverified offer, by the organization that owns its product. Returns ``404`` when the offer does not exist or the caller lacks ``VIEW`` on both. # List online offers Source: https://docs.closient.com/api-reference/retailers/online-offers/list-online-offers /openapi/openapi-retailers.json get /retailers/api/v1/organizations/{organization_id}/online-offers Return online (web-storefront) offers for the given organization — by default the ones it submitted; with ``scope=on_products``, also the unverified offers other organizations submitted on products it owns. Results are paginated with the standard ``{data, pagination}`` envelope; default ``page_size`` is 25, max is 100. Returns ``404`` when the organization does not exist or the caller lacks the ``VIEW`` permission on it. # Update online offer Source: https://docs.closient.com/api-reference/retailers/online-offers/update-online-offer /openapi/openapi-retailers.json put /retailers/api/v1/online-offers/{offer_id} Partially update an online offer. Omitted keys preserve existing values; ``metadata`` follows Stripe semantics (empty string deletes a key, ``null`` clears all). Returns ``404`` when the offer does not exist or the caller lacks ``MANAGE_OFFERS`` on the offer's owning organization. # Declare a retailer web property Source: https://docs.closient.com/api-reference/retailers/retailer-listings/declare-a-retailer-web-property /openapi/openapi-retailers.json post /retailers/api/v1/retailers/{retailer_id}/storefronts Create or update one of the retailer's online storefronts, keyed on its root URL. A retailer has one property per country it sells through online — `sephora.com` for the US, `sephora.ca` for Canada — and each carries its own product URLs, which is what lets a hosted page pick the right URL for the visitor's country. This endpoint exists because listings deliberately never create a storefront implicitly: at import scale that would fabricate retailer web properties nobody confirmed exist. Declare the property once here, then reference its root URL as `store_url` on listing rows. Sending the same `url` again updates that property rather than adding a second one. `retailer_id` is a `retailers.Retailer`. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account. # Drop one product from a retailer's catalog Source: https://docs.closient.com/api-reference/retailers/retailer-listings/drop-one-product-from-a-retailers-catalog /openapi/openapi-retailers.json delete /retailers/api/v1/retailers/{retailer_id}/listings/{listing_id} Drop a listing by clearing its `is_active` flag. **This is a soft delete and the row survives**, which the model asks for in the column's own definition: false once the retailer drops the product, history kept rather than deleted. A hard delete would also cascade the listing's per-country product URLs, so re-listing the product would mean rebuilding them from a feed. The listing keeps appearing in the list endpoint unless you filter with `is_active=true`, and `PATCH` with `is_active: true` re-lists it. Authorization is identical to creating a listing for this retailer: an organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account or the catalog-import service account. Whoever may upsert a listing may correct and retire it. # Get one online storefront Source: https://docs.closient.com/api-reference/retailers/retailer-listings/get-one-online-storefront /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id}/storefronts/{storefront_id} One web property of one retailer. The lookup is scoped to `retailer_id`, so a storefront id belonging to a different retailer is `404` rather than a cross-retailer read. Reading a canonical (Closient-curated) retailer's storefronts needs only an authenticated caller. An organization-private retailer requires view access on the organization that owns it, and a credential scoped to a different organization gets `404` rather than `403`, so the API never confirms that another organization's retailer exists. # Get one retailer product listing Source: https://docs.closient.com/api-reference/retailers/retailer-listings/get-one-retailer-product-listing /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id}/listings/{listing_id} One listing of one retailer, with its badges and per-property URLs resolved. The lookup is scoped to `retailer_id`, so a listing id belonging to a different retailer is `404` rather than a cross-retailer read. Reading a canonical (Closient-curated) retailer's listings needs only an authenticated caller. An organization-private retailer requires view access on the organization that owns it, and a credential scoped to a different organization gets `404` rather than `403`, so the API never confirms that another organization's retailer exists. # List a retailer's online storefronts Source: https://docs.closient.com/api-reference/retailers/retailer-listings/list-a-retailers-online-storefronts /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id}/storefronts Every online storefront (web property) declared for one retailer, paginated and including retired ones by default. This is the list a caller needs before sending listing URLs: a listing row's `store_url` must match an **active** storefront's root URL exactly, and a row naming one that does not exist comes back as `invalid` rather than creating the property. Reading the list first is how a caller tells a typo from a genuinely undeclared property. Reading a canonical (Closient-curated) retailer's storefronts needs only an authenticated caller. An organization-private retailer requires view access on the organization that owns it, and a credential scoped to a different organization gets `404` rather than `403`, so the API never confirms that another organization's retailer exists. # List a retailer's product listings Source: https://docs.closient.com/api-reference/retailers/retailer-listings/list-a-retailers-product-listings /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id}/listings Every product listing on file for one retailer, paginated, with each listing's badges and per-property URLs resolved. **Unfiltered by default, dropped products included**, so the count here is the population the catalog holds rather than the population still on sale. Pass `is_active=true` for only the live ones. Ordered by SKU, then by id so the ordering is total and paging cannot skip or repeat a listing whose SKU it shares with another (SKU is not unique, and imported listings frequently have none at all). Reading a canonical (Closient-curated) retailer's listings needs only an authenticated caller. An organization-private retailer requires view access on the organization that owns it, and a credential scoped to a different organization gets `404` rather than `403`, so the API never confirms that another organization's retailer exists. # Retire one online storefront Source: https://docs.closient.com/api-reference/retailers/retailer-listings/retire-one-online-storefront /openapi/openapi-retailers.json delete /retailers/api/v1/retailers/{retailer_id}/storefronts/{storefront_id} Retire a web property by clearing its `is_active` flag. **This is a soft delete and the row survives**, which the column's own definition asks for: a retired storefront keeps its historical offers and is excluded from discovery. It also keeps the row occupying its `(retailer, url)` slot, so declaring the same URL again through `POST /retailers/{retailer_id}/storefronts` reinstates this property rather than creating a second one beside it — and any listing URL still pointing here is deactivated for selection rather than left dangling at a row that vanished. There is no storefront `PATCH`: that same `POST` is an upsert keyed on the root URL, so sending the URL again with new values *is* the update. Authorization is identical to declaring a storefront for this retailer: an organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account or the catalog-import service account. Whoever may declare a storefront may retire it. # Update one retailer product listing Source: https://docs.closient.com/api-reference/retailers/retailer-listings/update-one-retailer-product-listing /openapi/openapi-retailers.json patch /retailers/api/v1/retailers/{retailer_id}/listings/{listing_id} Correct a single listing's own scalar columns — SKU, active flag, attributes, source reference. Only the fields present in the body are written. **Badges and per-property URLs are not writable here.** Both are synced as a whole set by `POST /retailers/{retailer_id}/listings`, under invariants that span a join (a badge belongs to its own retailer; a URL attaches to an active storefront of its own retailer). Send the listing through the upsert to change them. `attributes` replaces the stored dict rather than merging into it, which is what the upsert does with the same field — a merge would make removing a key impossible. Authorization is identical to creating a listing for this retailer: an organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account or the catalog-import service account. Whoever may upsert a listing may correct and retire it. # Upsert one retailer product listing Source: https://docs.closient.com/api-reference/retailers/retailer-listings/upsert-one-retailer-product-listing /openapi/openapi-retailers.json post /retailers/api/v1/retailers/{retailer_id}/listings Create or update a single retailer product listing. Identical to the batch endpoint with one row, and returns the same envelope with a single entry in `rows`. `retailer_id` is a `retailers.Retailer`. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account. # Upsert retailer product listings in bulk Source: https://docs.closient.com/api-reference/retailers/retailer-listings/upsert-retailer-product-listings-in-bulk /openapi/openapi-retailers.json post /retailers/api/v1/retailers/{retailer_id}/listings/batch Create or update up to 500 retailer product listings, keyed on (retailer, GTIN), returning a per-row result. Idempotent: rerunning an unchanged feed writes nothing new. Badges and per-property URLs are **synced** to each payload, so a badge the retailer dropped stops rendering on the next run — with one deliberate asymmetry: an empty `urls` list means 'this feed has no URL information' and deactivates nothing, while a non-empty list deactivates the properties it omits. A row whose GTIN has no product is reported as `unknown_gtin` and writes nothing; listings never create products, so per-field provenance is only ever written by the import engine. A row naming a storefront this retailer has no active property for is reported as `invalid` — declare the property with `POST /retailers/{retailer_id}/storefronts` first. `retailer_id` is a `retailers.Retailer`. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account. Returns 200 with a per-row error report even on partial success. Attribute-bound breaches are 422 naming the offending row, because the caller must change what it sends. # Get one physical store Source: https://docs.closient.com/api-reference/retailers/retailer-stores/get-one-physical-store /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id}/stores/{store_id} One store of one retailer, with its address flattened in. The lookup is scoped to `retailer_id`, so a store id belonging to a different retailer is `404` rather than a cross-retailer read. Reading a canonical (Closient-curated) retailer's stores needs only an authenticated caller — those rows are already public on the Directory pages. An organization-private retailer requires view access on the organization that owns it, and a credential scoped to a different organization gets `404` rather than `403`, so the API never confirms that another organization's retailer exists. # List a retailer's physical stores Source: https://docs.closient.com/api-reference/retailers/retailer-stores/list-a-retailers-physical-stores /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id}/stores Every physical store on file for one retailer, paginated. **Unfiltered by default, retired stores included.** The count returned here is therefore the same population the store upsert reports as `existing_stores`, which is the point: after an import whose outcome is unknown, this is how you ask what the catalog holds without re-running a dry-run import to find out. Pass `is_active=true` for only the live ones. Ordered by name, then by id so the ordering is total and paging cannot skip or repeat a store whose name it shares with another. Reading a canonical (Closient-curated) retailer's stores needs only an authenticated caller — those rows are already public on the Directory pages. An organization-private retailer requires view access on the organization that owns it, and a credential scoped to a different organization gets `404` rather than `403`, so the API never confirms that another organization's retailer exists. # Retire one physical store Source: https://docs.closient.com/api-reference/retailers/retailer-stores/retire-one-physical-store /openapi/openapi-retailers.json delete /retailers/api/v1/retailers/{retailer_id}/stores/{store_id} Retire a store by clearing its `is_active` flag. **This is a soft delete and the row survives**: `is_active` already is this model's retirement concept — `Retailer.is_physical` is derived from the presence of active stores — and a hard delete would cascade every in-store offer attached to it, discarding the inventory history a closed store is the evidence for. The store keeps appearing in the list endpoint unless you filter with `is_active=true`, and `PATCH` with `is_active: true` reinstates it. Authorization is identical to creating a store for this retailer: an organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account or the catalog-import service account. Whoever may upsert a store may correct and retire it. # Update one physical store Source: https://docs.closient.com/api-reference/retailers/retailer-stores/update-one-physical-store /openapi/openapi-retailers.json patch /retailers/api/v1/retailers/{retailer_id}/stores/{store_id} Correct a single store's own columns — name, store number, currency, GLN, active flag. Only the fields present in the body are written. **Address and coordinates are not writable here.** They live on the store's place and carry per-column provenance the reconciling upsert maintains; send an address correction to `POST /retailers/{retailer_id}/stores` instead, which also re-runs the duplicate matcher against it. A `store_number` another store of this retailer already holds returns `409`. Authorization is identical to creating a store for this retailer: an organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account or the catalog-import service account. Whoever may upsert a store may correct and retire it. # Upsert one physical store Source: https://docs.closient.com/api-reference/retailers/retailer-stores/upsert-one-physical-store /openapi/openapi-retailers.json post /retailers/api/v1/retailers/{retailer_id}/stores Reconcile and apply a single store row for one retailer. Identical to the batch endpoint with one row — the response is the same envelope, with a single entry in `rows` — so a one-store correction needs no list wrapping. Rows are **reconciled, not inserted**. Each row is tied to an existing store by exact store number, then by proximity within `proximity_m`, then by normalised street plus postal code — one-to-one, highest-confidence tier first. A match enriches the existing store in place; only a row that matches nothing creates one. This is why the endpoint is safe to point at a full dataset that overlaps rows already in the database: an insert-only surface would plant a duplicate beside every store whose recorded key predates the retailer's own numbering. Nothing is ever merged, deactivated or deleted here. An existing store no row claims is counted in `unmatched_existing` and left exactly as it is; a pair the matcher finds contradictory is reported in the row's `conflict` and left unrekeyed. `dry_run` defaults to **true**. Send `dry_run: false` to commit. `retailer_id` is a `retailers.Retailer`. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account, because those rows were previously writable only from a management command. The organization this credential acts for becomes the owning organization of any location record created. # Upsert physical stores in bulk Source: https://docs.closient.com/api-reference/retailers/retailer-stores/upsert-physical-stores-in-bulk /openapi/openapi-retailers.json post /retailers/api/v1/retailers/{retailer_id}/stores/batch Reconcile and apply up to 1,000 store rows for one retailer, returning a per-row result. Rows are **reconciled, not inserted**. Each row is tied to an existing store by exact store number, then by proximity within `proximity_m`, then by normalised street plus postal code — one-to-one, highest-confidence tier first. A match enriches the existing store in place; only a row that matches nothing creates one. This is why the endpoint is safe to point at a full dataset that overlaps rows already in the database: an insert-only surface would plant a duplicate beside every store whose recorded key predates the retailer's own numbering. Nothing is ever merged, deactivated or deleted here. An existing store no row claims is counted in `unmatched_existing` and left exactly as it is; a pair the matcher finds contradictory is reported in the row's `conflict` and left unrekeyed. `dry_run` defaults to **true**: every path executes against real rows inside a transaction that is then rolled back, so the counts and per-row outcomes are the matcher's real verdict rather than an estimate, and a first call writes nothing. Send `dry_run: false` to commit. **A dataset larger than one call.** Batch it, and send each call the `store_id`s the *other* batches' rows landed on as `already_claimed_store_ids`. Matching then stays one-to-one across the whole dataset — two rows in different batches can never both claim one existing store — and the last call's `unmatched_existing` is the dataset figure. Without it every census field is per call and `unmatched_existing` does not sum. `retailer_id` is a `retailers.Retailer`. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account, because those rows were previously writable only from a management command. The organization this credential acts for becomes the owning organization of any location record created. Returns 200 with a per-row error report even on partial success — only request-level failures produce 4xx. # Create retailer Source: https://docs.closient.com/api-reference/retailers/retailers/create-retailer /openapi/openapi-retailers.json post /retailers/api/v1/organizations/{organization_id}/retailers Create an org-private retailer for an organization. # Delete retailer Source: https://docs.closient.com/api-reference/retailers/retailers/delete-retailer /openapi/openapi-retailers.json delete /retailers/api/v1/retailers/{retailer_id} Soft-delete an org-private retailer (sets is_active=False). Cannot delete canonical retailers. # Get retailer Source: https://docs.closient.com/api-reference/retailers/retailers/get-retailer /openapi/openapi-retailers.json get /retailers/api/v1/retailers/{retailer_id} Get a specific retailer by ID. # List retailers Source: https://docs.closient.com/api-reference/retailers/retailers/list-retailers /openapi/openapi-retailers.json get /retailers/api/v1/retailers List canonical retailers and org-private retailers visible to the authenticated user. # Update retailer Source: https://docs.closient.com/api-reference/retailers/retailers/update-retailer /openapi/openapi-retailers.json patch /retailers/api/v1/retailers/{retailer_id} Update an org-private retailer. # Get capture status Source: https://docs.closient.com/api-reference/scanner/scanner-captures/get-capture-status /openapi/openapi-scanner.json get /scanner/api/v1/captures/{id} Retrieve the current processing status and data of a scan capture. # List scan captures Source: https://docs.closient.com/api-reference/scanner/scanner-captures/list-scan-captures /openapi/openapi-scanner.json get /scanner/api/v1/captures List the captures in the caller's scan sessions, newest first. An API key sees its organization's captures; any other credential sees the captures in sessions it opened. Paginated with the standard ``{data, pagination}`` envelope; default ``page_size`` is 25, max 100. # Retry a failed capture Source: https://docs.closient.com/api-reference/scanner/scanner-captures/retry-a-failed-capture /openapi/openapi-scanner.json post /scanner/api/v1/captures/{id}/retry Retry a failed scan capture by resetting its status and re-queuing the pipeline. # Submit a barcode or QR scan Source: https://docs.closient.com/api-reference/scanner/scanner-captures/submit-a-barcode-or-qr-scan /openapi/openapi-scanner.json post /scanner/api/v1/captures Submit a barcode or QR code scan and queue the processing pipeline. # Apply accepted proposals Source: https://docs.closient.com/api-reference/scanner/scanner-enrichment/apply-accepted-proposals /openapi/openapi-scanner.json post /scanner/api/v1/captures/{id}/apply Apply all accepted enrichment proposals to the capture's linked product. The product must be owned by the caller's organization: a product owned by another organization, or by none, returns ``404`` (the status never confirms who claimed a GTIN). A READONLY credential returns ``403``. # List enrichment proposals Source: https://docs.closient.com/api-reference/scanner/scanner-enrichment/list-enrichment-proposals /openapi/openapi-scanner.json get /scanner/api/v1/captures/{id}/proposals List AI-generated enrichment proposals for a capture, ordered by confidence. # Review an enrichment proposal Source: https://docs.closient.com/api-reference/scanner/scanner-enrichment/review-an-enrichment-proposal /openapi/openapi-scanner.json patch /scanner/api/v1/proposals/{proposal_id} Accept or reject a single enrichment proposal. # Resolve freshness chip thresholds for a scanned GTIN Source: https://docs.closient.com/api-reference/scanner/scanner-freshness/resolve-freshness-chip-thresholds-for-a-scanned-gtin /openapi/openapi-scanner.json get /scanner/api/v1/freshness-thresholds Return the color-coded freshness-chip thresholds for a scanned GTIN, resolved from the GPC-brick → category-threshold config (C-2861) and any per-brand organization overrides. Called by the public ``/scan/`` overlay after a Digital Link / DataMatrix decode: the chip first renders with the client-side default thresholds, then re-tints with the values returned here. Anonymous-allowed — no API key required. Always returns ``200`` with a usable threshold set: an unknown or malformed GTIN falls back to the global-default thresholds so the chip never breaks. # Delete a product photo Source: https://docs.closient.com/api-reference/scanner/scanner-photos/delete-a-product-photo /openapi/openapi-scanner.json delete /scanner/api/v1/sessions/{session_id}/photos/{photo_id} Delete a product photo and its S3 object. # Get a product photo Source: https://docs.closient.com/api-reference/scanner/scanner-photos/get-a-product-photo /openapi/openapi-scanner.json get /scanner/api/v1/sessions/{session_id}/photos/{photo_id} Retrieve one product photo of a scan session. A photo in any other session is a 404. # Get presigned upload URL Source: https://docs.closient.com/api-reference/scanner/scanner-photos/get-presigned-upload-url /openapi/openapi-scanner.json get /scanner/api/v1/sessions/{session_id}/presigned-upload-url Generate a presigned PUT URL for direct-to-bucket photo upload. # Link unlinked photos to a capture Source: https://docs.closient.com/api-reference/scanner/scanner-photos/link-unlinked-photos-to-a-capture /openapi/openapi-scanner.json post /scanner/api/v1/sessions/{session_id}/photos/link-capture Link all unlinked session photos to a specific barcode capture. # List product photos Source: https://docs.closient.com/api-reference/scanner/scanner-photos/list-product-photos /openapi/openapi-scanner.json get /scanner/api/v1/sessions/{session_id}/photos List the product photos of a scan session in display order. Paginated with the standard ``{data, pagination}`` envelope; default ``page_size`` is 25, max 100. # Register a product photo Source: https://docs.closient.com/api-reference/scanner/scanner-photos/register-a-product-photo /openapi/openapi-scanner.json post /scanner/api/v1/sessions/{session_id}/photos Register a product photo after it has been uploaded to S3. # Reorder product photos Source: https://docs.closient.com/api-reference/scanner/scanner-photos/reorder-product-photos /openapi/openapi-scanner.json post /scanner/api/v1/sessions/{session_id}/photos/reorder Set the display order of photos in the session gallery. # Update photo classification Source: https://docs.closient.com/api-reference/scanner/scanner-photos/update-photo-classification /openapi/openapi-scanner.json patch /scanner/api/v1/sessions/{session_id}/photos/{photo_id} Update the classification label of a product photo. # Resolve a QR code URL Source: https://docs.closient.com/api-reference/scanner/scanner-resolve/resolve-a-qr-code-url /openapi/openapi-scanner.json post /scanner/api/v1/resolve-url Follow the HTTP redirect chain for a QR code URL captured from product packaging and return the final canonical URL plus the intermediate hops. Used by the dual-scan flow: after the operator scans a QR code, the client posts the URL here, displays the resolved value, and (if accepted) calls ``/captures/{id}/save-redirect`` to persist it as a redirect rule on the product. Behavior: * Follows up to 10 HTTP 3xx redirects (301/302/303/307/308). * Refuses any hop whose host resolves to a non-public address (loopback, private, link-local/cloud-metadata, CGNAT, IPv6 ULA and similar); each redirect is re-checked and the connection is pinned to the checked address. The refusal reason is returned in ``error_message`` (e.g. ``Refused: URL resolves to a non-public address.``). * 5-second timeout per connect/read and 10 seconds for the whole chain; surfaces the error in ``error_message`` rather than failing the request when the URL is unreachable. * The final page body is not downloaded. * HTTPS upgrades (302 → ``https://``) are followed transparently. * meta-refresh HTML redirects are NOT followed in v1 — known limitation; document this in the brand-manager UI. * Tracking-parameter stripping is out of scope for v1; the URL is returned exactly as the final hop reports it. # Save a QR-resolved URL as a product redirect Source: https://docs.closient.com/api-reference/scanner/scanner-resolve/save-a-qr-resolved-url-as-a-product-redirect /openapi/openapi-scanner.json post /scanner/api/v1/captures/{short_id}/save-redirect Persist a URL — typically the ``final_url`` from ``/resolve-url`` — as a PRODUCT-scoped resolution rule on the product linked to this capture. Returns ``400`` when the capture has no linked product (the lookup pipeline either has not run yet or did not match). The client should retry once ``capture_status`` is ``COMPLETED``. The rule is written into the organization that owns the product, so the product must be one **your** organization owns: another organization's product returns ``404`` (the status never confirms who claimed a GTIN), a credential whose role cannot contribute returns ``403``, and an unclaimed product returns ``409`` — claim it first. The new rule is appended to the end of the product's existing rule list so it never silently jumps ahead of a manually-curated rule. Manage / reorder via the resolver UI. # Create a scan session Source: https://docs.closient.com/api-reference/scanner/scanner-sessions/create-a-scan-session /openapi/openapi-scanner.json post /scanner/api/v1/sessions Create a new scan session for barcode and QR code scanning. # End a scan session Source: https://docs.closient.com/api-reference/scanner/scanner-sessions/end-a-scan-session /openapi/openapi-scanner.json post /scanner/api/v1/sessions/{id}/end End an active scan session, preventing further captures. # Get session details Source: https://docs.closient.com/api-reference/scanner/scanner-sessions/get-session-details /openapi/openapi-scanner.json get /scanner/api/v1/sessions/{id} Retrieve detailed session information including all captures and product photos. # List scan sessions Source: https://docs.closient.com/api-reference/scanner/scanner-sessions/list-scan-sessions /openapi/openapi-scanner.json get /scanner/api/v1/sessions List the caller's scan sessions, newest first. An API key sees its organization's sessions; any other credential sees the sessions it opened. Paginated with the standard ``{data, pagination}`` envelope; default ``page_size`` is 25, max 100. # Poll session status Source: https://docs.closient.com/api-reference/scanner/scanner-sessions/poll-session-status /openapi/openapi-scanner.json get /scanner/api/v1/sessions/{id}/status Lightweight endpoint for polling session and capture completion status. # Upload a product image Source: https://docs.closient.com/api-reference/scanner/scanner-upload/upload-a-product-image /openapi/openapi-scanner.json post /scanner/api/v1/captures/upload-image Upload a product image and create a scan capture with optional barcode metadata. # Clear a search session Source: https://docs.closient.com/api-reference/search/search/clear-a-search-session /openapi/openapi-search.json delete /search/api/v1/search/session/{session_id} Permanently delete a session and all of its state. Use when the user abandons the search or explicitly resets. Idempotent against already-expired sessions only insofar as a missing session returns ``404`` — call this before the TTL elapses if you want a guaranteed delete. **Errors:** ``404 Not Found`` when the session doesn't exist. # Open a conversational search session Source: https://docs.closient.com/api-reference/search/search/open-a-conversational-search-session /openapi/openapi-search.json post /search/api/v1/search/session Create a new search session anchored to a location. The initial query is parsed for structured constraints (color, price, distance, availability, scope) and stored alongside the verbatim text. Sessions expire after 30 minutes of inactivity; submitting a query or removing a constraint refreshes the TTL. **Auth:** unauthenticated (anonymous IP-throttled at 30 requests/minute). Authenticated requests attach the session to the caller's user. **Errors:** ``422 Unprocessable Content`` when the payload fails validation (missing query, latitude/longitude out of WGS84 bounds). # Remove a constraint from the session Source: https://docs.closient.com/api-reference/search/search/remove-a-constraint-from-the-session /openapi/openapi-search.json delete /search/api/v1/search/session/{session_id}/constraints/{key} Remove one constraint by key from the active filter set without affecting the rest. Useful for a UI 'chip' pattern where each active constraint renders as a removable pill (e.g. clicking the X on the ``price_max`` chip calls ``DELETE /search/session/{id}/constraints/price_max``). Returns the full updated session state. The session's TTL is refreshed. **Errors:** ``404 Not Found`` when the session is missing/expired or the key is not in ``active_constraints``. # Report a post-search interaction (click, radius expand, etc.) for CTR-by-position measurement Source: https://docs.closient.com/api-reference/search/search/report-a-post-search-interaction-click-radius-expand-etc-for-ctr-by-position-measurement /openapi/openapi-search.json post /search/api/v1/search/click Fire-and-forget telemetry for the results page (C-4717) and any other Search v3 surface. Designed for `navigator.sendBeacon()` — no meaningful response body, always 204 regardless of whether `search_request_id` resolves to anything (an unresolved token — expired, garbage, or a rare race against the still-in-flight async demand-signal write — is logged, never surfaced as an error, since a client that already navigated away can't act on one anyway). **Auth:** unauthenticated (anonymous IP-throttled at 60 requests/minute, same as `GET /search/products`). # Search for a product and where to buy it nearby Source: https://docs.closient.com/api-reference/search/search/search-for-a-product-and-where-to-buy-it-nearby /openapi/openapi-search.json get /search/api/v1/search/products Two-stage product search: BM25 match against the product catalog (fuzzy, synonym-aware), then a geo-filtered lookup of physical store availability. Local results always precede online; online offers appear only when local results are empty or `include_online` is set. Pass `retailer_id` or `store_id` to scope the same pipeline to one retailer or one store — a store-scoped query skips distance sorting and just answers whether that store carries the product. **Auth:** unauthenticated (anonymous IP-throttled at 60 requests/minute). # Search the catalog Source: https://docs.closient.com/api-reference/search/search/search-the-catalog /openapi/openapi-search.json get /search/api/v1/search Full-text search across products, brands, retailers, substances, locations (physical stores), certifications, and traits. Uses PostgreSQL ``websearch`` parsing (the same syntax as a Google query box — quoted phrases, ``-exclusions``, and ``OR`` are honored) with ``ts_rank`` for relevance. Falls back to substring (``icontains``) matching when full-text returns no rows, so partial words like ``trai`` still surface relevant items. Pass ``?type=`` (repeatable) to limit the result set to a subset of resource collections. Results are merged across collections, ranked together, and truncated to ``limit``. **Auth:** unauthenticated (anonymous IP-throttled at 60 requests/minute). # Submit a follow-up query within a session Source: https://docs.closient.com/api-reference/search/search/submit-a-follow-up-query-within-a-session /openapi/openapi-search.json post /search/api/v1/search/session/{session_id}/query Send a follow-up query and let the classifier decide whether to refine the existing search or start fresh. Refinement language ("but cheaper", "in red", "within 2 km") merges constraints; explicit reset language ("instead", "never mind", "forget that") replaces the session's anchor and clears the constraint set. Either way, the response carries the classifier's verdict, the new constraint delta, the merged active set, and the updated history. The session's TTL is refreshed by 30 minutes on every successful call. **Errors:** ``404 Not Found`` when the session doesn't exist or has expired. # File an adverse report Source: https://docs.closient.com/api-reference/signals/adverse-reports/file-an-adverse-report /openapi/openapi-signals.json post /signals/api/v1/adverse-reports/ File a typed, severity-graded adverse report (illness / injury / allergic reaction / foreign object / spoilage / packaging defect) for the brand behind a scanned product. Requires an authenticated (social-auth) consumer. Returns a receipt carrying an immutable ``reference_id``, the delivery record, and a prefilled handoff to the appropriate regulator — Closient files with nobody on your behalf, and never gives medical advice. Rate-limited per identity and per IP (same tight feedback caps as signals). A banned identity is rejected with ``403``; an oversized body returns ``422``. # Get brand adverse report Source: https://docs.closient.com/api-reference/signals/adverse-reports/get-brand-adverse-report /openapi/openapi-signals.json get /signals/api/v1/brand/adverse-reports/{report_id}/ Retrieve one adverse report for a product the authenticated brand member's organization owns and has claimed. A report on an unowned or unclaimed product, or one that does not exist, reads as 404 (no existence oracle). # Get my adverse report Source: https://docs.closient.com/api-reference/signals/adverse-reports/get-my-adverse-report /openapi/openapi-signals.json get /signals/api/v1/adverse-reports/{reference_id}/ Retrieve one of the authenticated consumer's own adverse reports by its ``reference_id``. A reference that belongs to another consumer reads as 404 (no cross-consumer existence oracle). # List brand adverse reports Source: https://docs.closient.com/api-reference/signals/adverse-reports/list-brand-adverse-reports /openapi/openapi-signals.json get /signals/api/v1/brand/adverse-reports/ List adverse reports for products the authenticated brand member's organization OWNS and HAS CLAIMED, newest first. Unclaimed-brand reports are not retrievable. Supports filtering by product (``gtin``), ``batch``, ``report_type`` code, and date range (``date_from`` / ``date_to``). Uses the standard ``{data, pagination}`` envelope. # List lot-level adverse alerts Source: https://docs.closient.com/api-reference/signals/adverse-reports/list-lot-level-adverse-alerts /openapi/openapi-signals.json get /signals/api/v1/brand/adverse-report-alerts/ List lot-level early-warning alerts for products the authenticated brand member's organization OWNS and HAS CLAIMED — one alert per (product, lot) that crossed the count threshold. Unresolved only by default; pass ``include_resolved=true`` for history. Uses the ``{data, pagination}`` envelope. # List my adverse reports Source: https://docs.closient.com/api-reference/signals/adverse-reports/list-my-adverse-reports /openapi/openapi-signals.json get /signals/api/v1/adverse-reports/mine/ List the authenticated consumer's own adverse reports, newest first, with the delivery record and regulator handoff on each. Uses the standard ``{data, pagination}`` envelope. # Get brand signal Source: https://docs.closient.com/api-reference/signals/brand-signals/get-brand-signal /openapi/openapi-signals.json get /signals/api/v1/brand/signals/{signal_id}/ Retrieve one signal for a product the authenticated brand member's organization owns and has claimed. A signal on an unowned or unclaimed product, or one that does not exist, reads as 404 (no existence oracle). # List brand signals Source: https://docs.closient.com/api-reference/signals/brand-signals/list-brand-signals /openapi/openapi-signals.json get /signals/api/v1/brand/signals/ List signals for products the authenticated brand member's organization OWNS and HAS CLAIMED, newest first. Unclaimed-brand signals are not retrievable. Supports filtering by product (``gtin``), ``batch``, ``serial``, ``sentiment`` code, ``category`` code, date range (``date_from`` / ``date_to``), and ``contact_consent``. ``category``/``sentiment`` filters take stable codes, never labels. Uses the standard ``{data, pagination}`` envelope. # Report a signal as abusive Source: https://docs.closient.com/api-reference/signals/brand-signals/report-a-signal-as-abusive /openapi/openapi-signals.json post /signals/api/v1/brand/signals/{signal_id}/report/ Report a signal in the brand inbox as abusive (C-3316). Visible only for signals on products the authenticated brand member's organization owns and has claimed — an unowned/unclaimed or non-existent signal reads as 404 (no existence oracle). Filing a report does NOT hide the signal; it surfaces the signal to Closient internal review, which decides on suppression and/or a ban. Idempotent per reporter — reporting the same signal again updates your note rather than stacking duplicates. # Ban a submitter identity Source: https://docs.closient.com/api-reference/signals/signal-review/ban-a-submitter-identity /openapi/openapi-signals.json post /signals/api/v1/internal/bans/ Internal-review action: ban a submitter identity. While banned, the user is rejected with 403 at signal submission. Superuser-only (403 otherwise). Idempotent. Written to the audit trail recording who banned whom. # Lift a submitter ban Source: https://docs.closient.com/api-reference/signals/signal-review/lift-a-submitter-ban /openapi/openapi-signals.json delete /signals/api/v1/internal/bans/{user_id}/ Internal-review action: lift the ban on a submitter identity. Superuser-only (403 otherwise). Idempotent — lifting a non-existent ban still returns ok. Written to the audit trail. # List flagged signals (review queue) Source: https://docs.closient.com/api-reference/signals/signal-review/list-flagged-signals-review-queue /openapi/openapi-signals.json get /signals/api/v1/internal/signals/flagged/ Internal review queue of signals flagged by the text-hygiene heuristic, newest first. Superuser-only (403 otherwise). Includes suppressed signals so review can see prior actions; excludes consumer-revoked signals. Uses the standard ``{data, pagination}`` envelope. # List reported signals (review queue) Source: https://docs.closient.com/api-reference/signals/signal-review/list-reported-signals-review-queue /openapi/openapi-signals.json get /signals/api/v1/internal/signals/reported/ Internal review queue of signals a brand has reported as abusive, newest first (each signal once, regardless of how many brand members reported it). Superuser-only (403 otherwise). Uses the standard ``{data, pagination}`` envelope. # Restore a suppressed signal Source: https://docs.closient.com/api-reference/signals/signal-review/restore-a-suppressed-signal /openapi/openapi-signals.json post /signals/api/v1/internal/signals/{signal_id}/unsuppress/ Internal-review action: restore a previously suppressed signal to brand view. Superuser-only (403 otherwise). Idempotent. Written to the audit trail. # Suppress a signal Source: https://docs.closient.com/api-reference/signals/signal-review/suppress-a-signal /openapi/openapi-signals.json post /signals/api/v1/internal/signals/{signal_id}/suppress/ Internal-review action: pull a signal from brand view. The row is audit-retained but drops out of the brand inbox (same treatment as a consumer-revoked signal). Superuser-only (403 otherwise). Idempotent. Written to the audit trail. # Capture a voice note (transcription only) Source: https://docs.closient.com/api-reference/signals/signals/capture-a-voice-note-transcription-only /openapi/openapi-signals.json post /signals/api/v1/voice-sessions/ Create a transcription-only voice capture session for a signal. The transcript is produced client-side by the browser's Web Speech API — the same capture mechanism the existing voice-feedback pipeline uses — and stored as readable text; no audio bytes are uploaded by this endpoint. Returns the session id, which the consumer form then sends back as ``voice_session_id`` on the signal create call (linking it via ``Signal.voice_session``). This is the **transcription-only branch** of the in-production voice pipeline: it deliberately does NOT run the LLM enrichment / Linear-issue step. Transcription is a format conversion to make the note readable, not content analysis (epic dumb-pipe rule, C-3310). Requires an authenticated (social-auth) consumer. * ``201`` — session created. * ``400`` — empty/whitespace-only transcript. * ``429`` — submission rate limit exceeded (per-identity / per-IP, C-3316). # Edit my signal Source: https://docs.closient.com/api-reference/signals/signals/edit-my-signal /openapi/openapi-signals.json patch /signals/api/v1/signals/{signal_id}/ Edit one of the authenticated consumer's own ACTIVE signals. A revoked signal is frozen (403). Omitted fields are left unchanged; sending ``sentiment``/``contact_email`` as null clears them. ``category``/``sentiment`` are validated as known codes. # Erase my signal (hard delete) Source: https://docs.closient.com/api-reference/signals/signals/erase-my-signal-hard-delete /openapi/openapi-signals.json delete /signals/api/v1/signals/{signal_id}/ Permanently delete one of the authenticated consumer's own signals — the right-to-erasure / right-to-be-forgotten path (PIPEDA / GDPR). Unlike revoke (a soft status change that retains the row for audit), this removes the row and its content entirely (body, declared sentiment, consent, contact-address link). Works regardless of status — an already-revoked signal can still be erased. Returns 404 for a signal that isn't the caller's (no cross-consumer existence oracle). # Get my signal Source: https://docs.closient.com/api-reference/signals/signals/get-my-signal /openapi/openapi-signals.json get /signals/api/v1/signals/{signal_id}/ Retrieve one of the authenticated consumer's own signals by id. A signal that exists but belongs to another consumer reads as 404 (no cross-consumer existence oracle). # List my signals Source: https://docs.closient.com/api-reference/signals/signals/list-my-signals /openapi/openapi-signals.json get /signals/api/v1/signals/mine/ List the authenticated consumer's own signals, newest first, including revoked ones. Uses the standard ``{data, pagination}`` envelope. # List my verified contact addresses Source: https://docs.closient.com/api-reference/signals/signals/list-my-verified-contact-addresses /openapi/openapi-signals.json get /signals/api/v1/signals/contact-emails/ List the authenticated consumer's own verified account email addresses, primary first. These are the only addresses selectable as a signal's contact address (no freeform entry) — the same verified + owned constraint enforced on every write. Use it to populate the contact-address picker in the self-service UI. # Revoke my signal Source: https://docs.closient.com/api-reference/signals/signals/revoke-my-signal /openapi/openapi-signals.json post /signals/api/v1/signals/{signal_id}/revoke/ Soft-revoke one of the authenticated consumer's own signals. The row is retained for audit and simply drops out of brand view; status becomes ``revoked``. Idempotent on an already-revoked own signal. # Submit a signal Source: https://docs.closient.com/api-reference/signals/signals/submit-a-signal /openapi/openapi-signals.json post /signals/api/v1/signals/ Create a private feedback signal for the brand behind a scanned product. Requires an authenticated (social-auth) consumer. ``category`` and ``sentiment`` are stable codes, never labels. ``contact_email`` (if given) must be one of the consumer's own verified addresses. Abuse & safety baseline (C-3316 / C-3400): rate-limited per identity — a *low* daily cap (~3/day) plus an hourly anti-flood cap and a per-year ceiling — and per IP. Feedback is a rare action, so the caps are deliberately tight; exceeding any of them returns ``429`` with a friendly message and creates no records. Identical rapid resubmits are deduped (return the original, no duplicate). A banned identity is rejected with ``403``. An oversized body returns ``422``. Profanity/abuse heuristics *flag* the signal for review but never block it. # Get substance by ID Source: https://docs.closient.com/api-reference/substances/substances/get-substance-by-id /openapi/openapi-substances.json get /substances/api/v1/substances/{substance_id} Retrieve a single substance reference record by its public ID. # Search substances Source: https://docs.closient.com/api-reference/substances/substances/search-substances /openapi/openapi-substances.json get /substances/api/v1/substances Search the substance reference catalog by name, type, allergen, or hazard. Results are paginated via limit/offset and ordered by name. # Create end-of-life instruction Source: https://docs.closient.com/api-reference/sustainability/end-of-life-instructions/create-end-of-life-instruction /openapi/openapi-sustainability.json post /sustainability/api/v1/end-of-life-instructions Add a disposal instruction to one of the organization's products or one of its components. Requires CONTRIBUTE on the organization. 404 if the GTIN isn't a product the organization owns or a referenced id isn't the organization's. Nothing is created on error. # Delete end-of-life instruction Source: https://docs.closient.com/api-reference/sustainability/end-of-life-instructions/delete-end-of-life-instruction /openapi/openapi-sustainability.json delete /sustainability/api/v1/end-of-life-instructions/{record_id} Remove a end-of-life instruction. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get end-of-life instruction Source: https://docs.closient.com/api-reference/sustainability/end-of-life-instructions/get-end-of-life-instruction /openapi/openapi-sustainability.json get /sustainability/api/v1/end-of-life-instructions/{record_id} Fetch one end-of-life instruction. 404 if it doesn't exist or the caller can't see it. # List end-of-life instructions Source: https://docs.closient.com/api-reference/sustainability/end-of-life-instructions/list-end-of-life-instructions /openapi/openapi-sustainability.json get /sustainability/api/v1/end-of-life-instructions List end-of-life instructions across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update end-of-life instruction Source: https://docs.closient.com/api-reference/sustainability/end-of-life-instructions/update-end-of-life-instruction /openapi/openapi-sustainability.json patch /sustainability/api/v1/end-of-life-instructions/{record_id} Partial update: only the keys in the body are applied. The product cannot change; ``component_id`` may move it to another component of the same product. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Create environmental impact Source: https://docs.closient.com/api-reference/sustainability/environmental-impacts/create-environmental-impact /openapi/openapi-sustainability.json post /sustainability/api/v1/environmental-impacts Record a quantified environmental impact of one of the organization's products. Requires CONTRIBUTE on the organization. 404 if the GTIN isn't a product the organization owns or a referenced id isn't the organization's. Nothing is created on error. # Delete environmental impact Source: https://docs.closient.com/api-reference/sustainability/environmental-impacts/delete-environmental-impact /openapi/openapi-sustainability.json delete /sustainability/api/v1/environmental-impacts/{record_id} Remove a environmental impact. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get environmental impact Source: https://docs.closient.com/api-reference/sustainability/environmental-impacts/get-environmental-impact /openapi/openapi-sustainability.json get /sustainability/api/v1/environmental-impacts/{record_id} Fetch one environmental impact. 404 if it doesn't exist or the caller can't see it. # List environmental impacts Source: https://docs.closient.com/api-reference/sustainability/environmental-impacts/list-environmental-impacts /openapi/openapi-sustainability.json get /sustainability/api/v1/environmental-impacts List environmental impacts across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update environmental impact Source: https://docs.closient.com/api-reference/sustainability/environmental-impacts/update-environmental-impact /openapi/openapi-sustainability.json patch /sustainability/api/v1/environmental-impacts/{record_id} Partial update: only the keys in the body are applied. The product cannot change; delete and recreate to move it. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Create packaging element Source: https://docs.closient.com/api-reference/sustainability/packaging/create-packaging-element /openapi/openapi-sustainability.json post /sustainability/api/v1/packaging Record a packaging element of one of the organization's products. Requires CONTRIBUTE on the organization. 404 if the GTIN isn't a product the organization owns or a referenced id isn't the organization's. Nothing is created on error. # Delete packaging element Source: https://docs.closient.com/api-reference/sustainability/packaging/delete-packaging-element /openapi/openapi-sustainability.json delete /sustainability/api/v1/packaging/{record_id} Remove a packaging element. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get packaging element Source: https://docs.closient.com/api-reference/sustainability/packaging/get-packaging-element /openapi/openapi-sustainability.json get /sustainability/api/v1/packaging/{record_id} Fetch one packaging element. 404 if it doesn't exist or the caller can't see it. # List packaging elements Source: https://docs.closient.com/api-reference/sustainability/packaging/list-packaging-elements /openapi/openapi-sustainability.json get /sustainability/api/v1/packaging List packaging elements across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update packaging element Source: https://docs.closient.com/api-reference/sustainability/packaging/update-packaging-element /openapi/openapi-sustainability.json patch /sustainability/api/v1/packaging/{record_id} Partial update: only the keys in the body are applied. The product cannot change; delete and recreate to move it. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Create production process Source: https://docs.closient.com/api-reference/sustainability/production-processes/create-production-process /openapi/openapi-sustainability.json post /sustainability/api/v1/production-processes Record a production process stage of one of the organization's products. 422 if ``end_date`` is before ``start_date``. Requires CONTRIBUTE on the organization. 404 if the GTIN isn't a product the organization owns or a referenced id isn't the organization's. Nothing is created on error. # Delete production process Source: https://docs.closient.com/api-reference/sustainability/production-processes/delete-production-process /openapi/openapi-sustainability.json delete /sustainability/api/v1/production-processes/{record_id} Remove a production process. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get production process Source: https://docs.closient.com/api-reference/sustainability/production-processes/get-production-process /openapi/openapi-sustainability.json get /sustainability/api/v1/production-processes/{record_id} Fetch one production process. 404 if it doesn't exist or the caller can't see it. # List process types Source: https://docs.closient.com/api-reference/sustainability/production-processes/list-process-types /openapi/openapi-sustainability.json get /sustainability/api/v1/process-types The catalog of active production process types. Pass a ``code`` from here as ``process_type_code`` when creating a production process. # List production processes Source: https://docs.closient.com/api-reference/sustainability/production-processes/list-production-processes /openapi/openapi-sustainability.json get /sustainability/api/v1/production-processes List production processes across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update production process Source: https://docs.closient.com/api-reference/sustainability/production-processes/update-production-process /openapi/openapi-sustainability.json patch /sustainability/api/v1/production-processes/{record_id} Partial update: only the keys in the body are applied. The product cannot change; delete and recreate to move it. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Create recyclability assessment Source: https://docs.closient.com/api-reference/sustainability/recyclability/create-recyclability-assessment /openapi/openapi-sustainability.json post /sustainability/api/v1/recyclability Record the recyclability assessment of a packaging element. 409 if the element already has one. Requires CONTRIBUTE on the organization. 404 if the packaging element isn't one of the organization's or a referenced id isn't the organization's. Nothing is created on error. # Delete recyclability assessment Source: https://docs.closient.com/api-reference/sustainability/recyclability/delete-recyclability-assessment /openapi/openapi-sustainability.json delete /sustainability/api/v1/recyclability/{record_id} Remove a recyclability assessment. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get recyclability assessment Source: https://docs.closient.com/api-reference/sustainability/recyclability/get-recyclability-assessment /openapi/openapi-sustainability.json get /sustainability/api/v1/recyclability/{record_id} Fetch one recyclability assessment. 404 if it doesn't exist or the caller can't see it. # List recyclability assessments Source: https://docs.closient.com/api-reference/sustainability/recyclability/list-recyclability-assessments /openapi/openapi-sustainability.json get /sustainability/api/v1/recyclability List recyclability assessments across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update recyclability assessment Source: https://docs.closient.com/api-reference/sustainability/recyclability/update-recyclability-assessment /openapi/openapi-sustainability.json patch /sustainability/api/v1/recyclability/{record_id} Partial update: only the keys in the body are applied. The packaging element cannot change; delete and recreate to move it. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Create substance of concern Source: https://docs.closient.com/api-reference/sustainability/substances-of-concern/create-substance-of-concern /openapi/openapi-sustainability.json post /sustainability/api/v1/substances-of-concern Record a regulated substance present in one of the organization's products. Requires CONTRIBUTE on the organization. 404 if the GTIN isn't a product the organization owns or a referenced id isn't the organization's. Nothing is created on error. # Delete substance of concern Source: https://docs.closient.com/api-reference/sustainability/substances-of-concern/delete-substance-of-concern /openapi/openapi-sustainability.json delete /sustainability/api/v1/substances-of-concern/{record_id} Remove a substance of concern. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get substance of concern Source: https://docs.closient.com/api-reference/sustainability/substances-of-concern/get-substance-of-concern /openapi/openapi-sustainability.json get /sustainability/api/v1/substances-of-concern/{record_id} Fetch one substance of concern. 404 if it doesn't exist or the caller can't see it. # List substances of concern Source: https://docs.closient.com/api-reference/sustainability/substances-of-concern/list-substances-of-concern /openapi/openapi-sustainability.json get /sustainability/api/v1/substances-of-concern List substances of concern across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update substance of concern Source: https://docs.closient.com/api-reference/sustainability/substances-of-concern/update-substance-of-concern /openapi/openapi-sustainability.json patch /sustainability/api/v1/substances-of-concern/{record_id} Partial update: only the keys in the body are applied. The substance cannot change; delete and recreate it. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Get product sustainability data Source: https://docs.closient.com/api-reference/sustainability/sustainability/get-product-sustainability-data /openapi/openapi-sustainability.json get /sustainability/api/v1/products/{gtin}/sustainability Retrieve aggregated sustainability data for a product identified by GTIN: circularity scores, recyclability information, packaging breakdown, major components, end-of-life instructions, and quantified environmental impact assessments. Empty sections are returned as ``null`` (single records) or empty lists; the response shape is stable regardless of how much data is available for the product. # Create transport impact Source: https://docs.closient.com/api-reference/sustainability/transport-impacts/create-transport-impact /openapi/openapi-sustainability.json post /sustainability/api/v1/transport-impacts Record a transport leg of one of the organization's products. Requires CONTRIBUTE on the organization. 404 if the GTIN isn't a product the organization owns or a referenced id isn't the organization's. Nothing is created on error. # Delete transport impact Source: https://docs.closient.com/api-reference/sustainability/transport-impacts/delete-transport-impact /openapi/openapi-sustainability.json delete /sustainability/api/v1/transport-impacts/{record_id} Remove a transport impact. The product is untouched. Requires CONTRIBUTE on the owning organization. # Get transport impact Source: https://docs.closient.com/api-reference/sustainability/transport-impacts/get-transport-impact /openapi/openapi-sustainability.json get /sustainability/api/v1/transport-impacts/{record_id} Fetch one transport impact. 404 if it doesn't exist or the caller can't see it. # List transport impacts Source: https://docs.closient.com/api-reference/sustainability/transport-impacts/list-transport-impacts /openapi/openapi-sustainability.json get /sustainability/api/v1/transport-impacts List transport impacts across the organizations the caller can see, ordered by product GTIN. Filter by organization or by product GTIN. Paginated. # Update transport impact Source: https://docs.closient.com/api-reference/sustainability/transport-impacts/update-transport-impact /openapi/openapi-sustainability.json patch /sustainability/api/v1/transport-impacts/{record_id} Partial update: only the keys in the body are applied. The product cannot change; delete and recreate to move it. A null clears a nullable field; an empty body is a 422. Requires CONTRIBUTE on the owning organization. # Authentication Source: https://docs.closient.com/authentication API key types, formats, RBAC roles, and security practices. All API endpoints require authentication unless explicitly marked as public. ## API Key Authentication Include your API key in the `X-API-Key` header: ```bash theme={null} curl -H "X-API-Key: csb_AbCdEfGhIjKlMnOpQrStUvWxYzAbCdEf_1a2b3c4d" \ https://www.closient.com/products/api/v1/products ``` ### Key Format ``` __ ``` | Component | Description | | - | - | | `prefix` | Key type identifier (`csb` or `cpk`) | | `body` | 32 random alphanumeric characters | | `checksum` | 8-character lowercase hex CRC-32 digest of the body | The checksum enables client-side format validation before making a request: ```python theme={null} import zlib def verify_api_key(api_key: str) -> bool: parts = api_key.split("_") if len(parts) != 3: return False prefix, body, checksum = parts if prefix not in ("csb", "cpk"): return False expected = format(zlib.crc32(body.encode("utf-8")) & 0xFFFFFFFF, "08x") return checksum == expected ``` ### Key Types | Prefix | Name | Scope | Use Case | | - | - | - | - | | `csb` | Business | Organization | Server-side API access at the key's role | | `cpk` | Publishable | Organization | Client-side widgets and embeds — safe to expose in frontend code | Every key belongs to exactly one organization and acts **only** on that organization, at the role it was given when it was minted. The person who created the key is recorded for the audit trail; their other memberships never extend the key's reach, and a key created by an Owner is not an Owner. To act on two organizations you need two keys. **Publishable keys** (`cpk`) differ from the other types in that they are: * Scoped to a single organization (returns org context, not user context) * Limited to a lower default rate (100 requests/minute) suited to client-side use Per-key rate limits (`rate_limit_per_minute`, `rate_limit_per_day`) and optional expiration (`expires_at`) apply to **every** key type, not just publishable ones — see [Rate Limits](/rate-limits). ### Key Lifecycle Keys are shown **once** at creation time. The raw key is never stored — only its SHA-256 hash. | Field | Description | | - | - | | `is_active` | Set to `false` to immediately revoke | | `expires_at` | Optional expiration datetime (rejected after expiry) | | `last_used` | Updated on each authenticated request | | `rate_limit_per_minute` | Per-key override (0 = use global default) | | `rate_limit_per_day` | Per-key override (0 = use global default) | ## Organization Roles (RBAC) Users belong to organizations through memberships, each with a role: | Role | Manage Org | Billing | Members | Offers | API Keys | | - | - | - | - | - | - | | **Owner** | Yes | Yes | Yes | Yes | Yes | | **Manager** | No | Yes | Yes | Yes | Yes | | **Billing** | No | Yes | No | No | No | | **Editor** | No | No | No | Yes | No | Roles are evaluated per-organization. A user can be an Owner of one organization and an Editor of another. An API key has its own role (`manager`, `editor` or `readonly`), evaluated the same way but only ever on the key's own organization. A `readonly` key minted by an Owner can read that organization and nothing more. ### Permission Reference | Permission | Required Role | Reachable with an API key? | | - | - | - | | `organization.view_organization` | Any member | Yes — any key role, on the key's organization | | `organization.contribute_organization` | Owner, Manager, or Editor | Yes — key role `editor` or `manager`, on the key's organization | | `organization.change_organization` | Owner or Manager | Yes — key role `manager`, on the key's organization | | `organization.manage_offers` | Owner, Manager, or Editor | Yes — key role `editor` or `manager`, on the key's organization | | `organization.manage_billing` | Owner, Manager, or Billing | **No** | | `organization.manage_api_keys` | Owner or Manager | **No** | | `organization.manage_members` | Owner or Manager | **No** | | `organization.manage_organization` | Owner only | **No** | | `organization.tombstone_organization` | Owner, or an owner-issued Manager-role key | Yes — key role `manager`, on the key's own organization, and minted by a *current* owner of it | | `organization.delete_organization` | Owner only | **No** | `organization.tombstone_organization` gates `DELETE /organizations/{id}` itself — the reversible 90-day tombstone (C-6197). `organization.delete_organization` is the narrower, still fully human-only permission behind the truly irreversible operations: fast-purge and restore. The four permissions marked **No** are reachable only from a signed-in browser session. An API key is refused with `403` even when the person who created it holds the role — changing who belongs to an organization, moving money, minting further keys, and the operations that follow a tombstone (fast-purge, restore) all require a human at a keyboard. This is a property of the *credential*, not of the person: it applies to keys created by owners and by staff alike, and to OAuth bearer tokens. It exists so that a leaked key is bounded by what a key can do, rather than by whoever happened to create it. Automate these five against the Dashboard instead. ## Session Authentication Browser-based access (Dashboard, admin) uses Django session cookies. This is automatic when logged in and is primarily for internal use. API integrations should always use API key authentication. ## Security Best Practices * **Never expose `csb` keys** in client-side code — use `cpk` publishable keys instead * **Store keys in environment variables**, not in source code * **Rotate keys periodically** and revoke unused ones * **Use per-key rate limits** on publishable keys to prevent abuse * **Set expiration dates** on keys used for temporary integrations # Changelog Source: https://docs.closient.com/changelog Product updates, new features, and improvements to the Closient platform. ## New features **GS1 Digital Link scanning.** Scanners can now POST a GS1 Digital Link URL as `raw_value` and receive parsed GTIN, lot, expiry, and serial back in a single capture. Repeat scans hit a cached resolver URL on the product. See [GS1 Digital Link](/guides/digital-link-resolution). **Recall activity dashboard.** A new compliance analytics view at `/compliance/recalls/activity/` shows recalls by source system and severity, affected GTINs over time, time-to-acknowledgement (p50/p90), and consumer scan volume on recalled items over the last 90 days. **Brand recall review queue.** When a regulator recall (FDA, FSIS, CPSC, Health Canada) fuzzy-matches a claimed brand, candidates now land in `/compliance/recall-review/` for one-click approve or reject. Approvals attach the recall to the product; rejections suppress the pair permanently. **FSMA 204 compliance dashboard.** A new traffic-light dashboard at `/compliance/` scores FTL coverage, CTE completeness, traceability plan status, and trading-partner sharing on a 0–100 scale. **Mock FDA audit drill.** A "Run mock audit" button on the compliance dashboard runs the full export pipeline, times it, walks each Critical Tracking Event for required Key Data Elements, and grades the org green / yellow / red against the FSMA 204 24-hour rule. **FDA response package export.** `GET /organizations//compliance/fda-export.json` returns a complete FSMA 204 snapshot — current traceability plan, Critical Tracking Events with all KDEs, lots, reference documents, trading partners, and locations. **Trading partners.** Manage suppliers and recipients as first-class records via the new `/epcis/api/v1/trading-partners` API. Critical Tracking Events now link to structured `immediate_previous_source` and `immediate_subsequent_recipient` records, with FDA exports including FEI, GLN, and exempt-supplier flags. **Reference documents.** Bills of lading, purchase orders, invoices, ASNs, and receipts are now first-class objects via `/epcis/api/v1/reference-documents`. A single document can link to many CTEs (one BOL across multiple lots) and flows through to FDA exports. **Traceability plans.** Append-only versioned traceability plans are now generated per organization with FSMA-required designated contact, FTL product list, performed CTE types, and procedures. Prior versions are retained for the regulator-required 2-year window. **Lot quantity & unit of measure.** Product lots now carry `quantity` and `unit_of_measure` (GS1 UN/ECE Recommendation 20 codes — EA, CS, PLT, KGM, LBR, LTR, etc.). FDA JSON and CSV exports include both. CTE event-level UOM uses the same controlled list. **Critical Tracking Event coverage.** CTEs now support all seven FSMA 204 event types — Growing, Cooling, Initial Pack, and Land-Based Receive in addition to Harvest, Receive, Transform, and Ship. Per-type Key Data Element validation runs at save time. First-receive vessel name, identifier, and species fields are available for land-based seafood. **Product ingredients with FTL inheritance.** Products can now declare ingredient products. A finished product is automatically treated as FSMA 204 covered if any ingredient is on the Food Traceability List, with the triggering ingredients surfaced in the UI. **TLC auto-assignment for exempt suppliers.** Receive events from exempt trading partners (small farms, restaurants) now auto-generate Traceability Lot Codes from a per-org configurable pattern (`{prefix}-{date}-{sequence}` by default), so unidentified incoming lots get a compliant identifier without manual entry. **Structured lot pattern matching for recalls.** The recall matcher now parses structured lot patterns — exact codes, prefixes, and ranges (`Lots 2024001-2024050`, `LOT A123 through A150`) — instead of relying solely on substring search. **Sectioned product editor.** The product editor now ships with dedicated **Lots** and **Media** panes. Lots shows produced date, expiry, serial count, resolver state, and a derived state chip (Expired / Paused / In market). Media shows a responsive grid with primary-image chip and lazy-loaded thumbnails. **Closient lookup pre-fill.** Entering a GTIN on the product-add form now silently checks the catalog and offers to pre-fill known fields, with a low-confidence notice for crowd-sourced data. **What's New panel.** The dashboard home now shows admin-published platform changelog entries in a "What's New" card so brands can see platform improvements as they ship. **Subscription value page.** A new `/subscription-value/` page explains how the platform improves over time — auto-improving hosted pages, scan anomaly alerts, AI-traffic monitoring, compounding intelligence by month 1 / 3 / 12, and workflow integrations. ## Updates **Resolver caching.** The full GS1 Digital Link resolver pipeline is now backed by a 5-minute read-through cache, keyed by GTIN, batch, serial, link type, language, and country. Recall override checks always run before the cache, so recalled products are never served stale. Resolver rule edits invalidate affected entries immediately. **Faster public pages.** Public marketing, learn, and `.well-known` pages now ship with `Cache-Control: public` headers, removing the `Vary: Cookie` blocker that was preventing CDN caching of anonymous responses. **Lot immutability and 2-year retention.** Once a Critical Tracking Event references a lot, that lot's identifier and provenance fields lock automatically. A 2-year retention attestation is recorded per FSMA 204 § 1.1455(g). **JSON-LD structured data.** Resolver hosted pages and certification pages now emit `application/ld+json` Schema.org `Product` blocks, including nested `hasCertification` details, for richer search and AI-agent discovery. **AI discovery surfaces.** `/.well-known/ai-plugin.json`, `/.well-known/mcp.json`, and `/.well-known/agent.json` now point at the Agentic Commerce API at `/agent/api/v1/` (resolve, products, availability) rather than the search API spec, and cross-reference each other for shopping-assistant tool discovery. **Extended LLM index.** A new `/llms-full.txt` endpoint serves an 8.4 KB Markdown content index of Closient's platform overview, API surfaces, learn sections, pricing, and integration points. `/llms.txt` now links to it. ## Bug fixes **Reply tokens to inbound email.** Per-conversation `reply+@email.closient.com` addresses now route through correctly. The SES inbound rule was matching only the literal `inbound@` recipient, silently dropping reply addresses with no bounce. # Louisiana SB 14 Source: https://docs.closient.com/compliance/louisiana-sb14 Louisiana SB 14 (Act 463 of 2025) requires manufacturers of certain food products sold in Louisiana to disclose 44 listed ingredients via a scannable QR code on the package. This page covers what the statute requires, the full ingredient list, exemptions, and how to configure Closient to satisfy the obligation. Louisiana [Senate Bill 14](https://legis.la.gov/legis/ViewDocument.aspx?d=1426851), enacted as **Act 463 of the 2025 Regular Session**, requires manufacturers of certain food products sold for human consumption in Louisiana to disclose any of 44 listed ingredients via a QR code on the package. The labeling requirement takes effect for products manufactured on or after **January 1, 2028**. > **Not legal advice.** This summary is provided for engineering and operations teams configuring Closient. The statutory text is the controlling authority. Verify the ingredient list, effective dates, and exemption scope against the [enrolled bill](https://legis.la.gov/legis/ViewDocument.aspx?d=1426851) and consult Louisiana counsel before relying on this page for compliance. ## What the statute requires LA R.S. 40:661 (as enacted by Act 463 of 2025) requires that any food product manufactured on or after January 1, 2028, sold for human consumption in Louisiana, and containing one or more of the listed ingredients, bear: 1. **A QR code on the package** (LA R.S. 40:661(B)(1)) that, when scanned, links to 2. **A web page under the control of the manufacturer** (LA R.S. 40:661(B)(2)) that surfaces 3. **The presence of the listed ingredient and accompanying safety information** (LA R.S. 40:661(B)(3)) The statute does not prescribe the format of the disclosure page. The "control of the manufacturer" language is the operative phrase — see [Manufacturer Control](/compliance/manufacturer-control) for why Closient's resolver-based architecture satisfies this requirement. ## The 44-ingredient list The Louisiana statute lists 44 ingredients that trigger the disclosure obligation if any of them are present in a covered food product. The list mixes preservatives, color additives, dough conditioners, and synthetic compounds. > The list below is a working summary for engineering reference. The [enrolled bill text](https://legis.la.gov/legis/ViewDocument.aspx?d=1426851) is the controlling source — verify every entry before treating any list as canonical, and re-verify after each legislative session in case of amendments. | # | Ingredient | Category | Notes | | - | - | - | - | | 1 | Potassium bromate | Dough conditioner | Banned in EU, UK, Brazil, Canada | | 2 | Propylparaben | Preservative | Banned in EU | | 3 | Azodicarbonamide (ADA) | Dough conditioner | Banned in EU, UK, Australia | | 4 | Butylated hydroxyanisole (BHA) | Antioxidant preservative | Listed as Cal Prop 65 carcinogen | | 5 | Butylated hydroxytoluene (BHT) | Antioxidant preservative | Restricted in EU | | 6 | Brominated vegetable oil (BVO) | Emulsifier | FDA revoked authorization Aug 2024 | | 7 | Red Dye 3 (FD\&C Red No. 3) | Color additive | FDA revoked food use Jan 2025 | | 8 | Red Dye 40 (FD\&C Red No. 40) | Color additive | EU requires warning label | | 9 | Yellow Dye 5 (FD\&C Yellow No. 5, tartrazine) | Color additive | EU requires warning label | | 10 | Yellow Dye 6 (FD\&C Yellow No. 6) | Color additive | EU requires warning label | | 11 | Blue Dye 1 (FD\&C Blue No. 1) | Color additive | | | 12 | Blue Dye 2 (FD\&C Blue No. 2) | Color additive | | | 13 | Green Dye 3 (FD\&C Green No. 3) | Color additive | | | 14 | Citrus Red 2 (FD\&C Citrus Red No. 2) | Color additive | Permitted only for orange skin coloring | | 15 | Orange B | Color additive | Permitted only for hot dog/sausage casings | | 16 | Acesulfame potassium (Ace-K) | Artificial sweetener | | | 17 | Aspartame | Artificial sweetener | IARC Group 2B (possibly carcinogenic) | | 18 | Saccharin | Artificial sweetener | | | 19 | Sucralose | Artificial sweetener | | | 20 | Neotame | Artificial sweetener | | | 21 | Advantame | Artificial sweetener | | | 22 | Bleached flour | Flour treatment | Bleaching agents (benzoyl peroxide, chlorine) | | 23 | Bromated flour | Flour treatment | Contains potassium bromate | | 24 | Calcium bromate | Dough conditioner | | | 25 | Potassium aluminum sulfate | Leavening / firming agent | Aluminum-containing additive | | 26 | Sodium aluminum sulfate | Leavening agent | Aluminum-containing additive | | 27 | Sodium aluminum phosphate | Leavening agent | Aluminum-containing additive | | 28 | Aluminum ammonium sulfate | Leavening agent | Aluminum-containing additive | | 29 | Diacetyl | Flavoring | Linked to bronchiolitis obliterans | | 30 | Propyl gallate | Antioxidant preservative | | | 31 | TBHQ (tert-butylhydroquinone) | Antioxidant preservative | | | 32 | Olestra | Fat substitute | | | 33 | Partially hydrogenated oils (PHOs) | Fat / trans-fat source | FDA phased out 2018; some uses persist | | 34 | Sodium nitrite | Cured-meat preservative | IARC Group 2A in processed-meat context | | 35 | Sodium nitrate | Cured-meat preservative | | | 36 | Carrageenan | Thickener / emulsifier | | | 37 | Carboxymethylcellulose (CMC) | Emulsifier / thickener | | | 38 | Polysorbate 60 | Emulsifier | | | 39 | Polysorbate 80 | Emulsifier | | | 40 | Sodium benzoate | Preservative | Can form benzene with ascorbic acid | | 41 | Potassium benzoate | Preservative | | | 42 | Sulfites (sulfur dioxide / sodium sulfite / sodium bisulfite / potassium metabisulfite) | Preservative | FDA mandatory disclosure if > 10 ppm | | 43 | Calcium propionate | Mold inhibitor | | | 44 | Sodium propionate | Mold inhibitor | | The statute does not provide CAS numbers; matching against the catalog is by chemical/ingredient name. Where multiple chemical forms of the same family exist (sulfites, parabens, aluminum salts), the statute lists each form separately or uses an umbrella term — see the bill text for the canonical naming. > Texas SB 25 covers a substantially overlapping 44-ingredient list but adds **DATEM, ficin, and titanium dioxide** that LA SB 14 omits, and omits a small number of ingredients (azodicarbonamide variants among them) that LA SB 14 includes. Treat the per-state list as authoritative for the corresponding jurisdiction. See [Texas SB 25](/compliance/texas-sb25) for the TX-specific list. ## Effective dates | Event | Date | | - | - | | Act 463 signed into law | June 2025 | | Statute generally effective | August 1, 2025 | | Labeling requirements enforceable for products manufactured on or after | **January 1, 2028** | Products manufactured before January 1, 2028 are not subject to the QR-code labeling requirement under the grandfathering provision; verify against the enrolled text for the precise grandfathering scope. ## Exemptions LA SB 14 does not apply to: * **Drugs** regulated under the federal Food, Drug, and Cosmetic Act * **Dietary supplements** regulated as such under federal law * **Alcoholic beverages** regulated by the TTB / Louisiana Office of Alcohol and Tobacco Control * **Retail-prepared food** (food prepared and offered for immediate consumption at retail — restaurant meals, grocery hot bars, etc.) * **Medical foods** as defined under federal law Verify exemption scope against the enrolled bill — definitions matter, especially the line between "dietary supplement" and "conventional food fortified with supplemental ingredients." ## Enforcement Violations of LA SB 14 are enforced under the Louisiana Unfair Trade Practices and Consumer Protection Law (LA R.S. 51:1401 et seq.). The Attorney General has primary enforcement authority. Penalties follow the standard Unfair Trade Practices framework; consult Louisiana counsel for the current penalty structure. There is no FDA pre-emption argument for this disclosure obligation — it is a state-law labeling requirement layered on top of federal labeling, similar in structure to Cal Prop 65. ## Configuring Closient for LA SB 14 Two resolver rules per covered product: one for the ingredient disclosure surface, one for the safety information surface. ```bash theme={null} # Ingredient disclosure page curl -X POST https://www.closient.com/v1/products/{gtin}/resolver-rules \ -H "X-API-Key: $CLOSIENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "link_type": "gs1:ingredientsInfo", "destination_type": "HOSTED_PAGE", "hosted_page_id": "page_ingredients_01HXYZ...", "scope": "PRODUCT" }' # Safety information page (LA R.S. 40:661(B)(3)) curl -X POST https://www.closient.com/v1/products/{gtin}/resolver-rules \ -H "X-API-Key: $CLOSIENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "link_type": "gs1:safetyInfo", "destination_type": "HOSTED_PAGE", "hosted_page_id": "page_safety_01HXYZ...", "scope": "PRODUCT" }' ``` Both pages can be authored in the [Closient dashboard](https://www.closient.com/dashboard/) under the product's resolver configuration, or pointed at custom URLs the brand operates. See [Resolver Rules](/user-guides/resolver-rules) for the full configuration UI. The QR code on the package should encode the canonical resolver URL: ``` https://www.closient.com/01/{gtin} ``` Louisiana counsel may prefer that the printed QR target a state-specific URL (`...?context=la-sb14`) to scope the disclosure to the regulated jurisdiction. Closient supports per-locale routing via [resolver rules](/user-guides/resolver-rules); discuss with counsel before electing a scoped URL strategy. ## Related * [US State Ingredient Disclosure overview](/compliance/us-state-ingredient-disclosure) * [Texas SB 25](/compliance/texas-sb25) — the sibling Texas law (overlapping list, earlier effective date) * [Manufacturer Control](/compliance/manufacturer-control) — why a neutral resolver satisfies the "control of the manufacturer" requirement * [LA SB 14 enrolled text](https://legis.la.gov/legis/ViewDocument.aspx?d=1426851) (Louisiana Legislature) * [LA SB 14 bill page](https://legis.la.gov/legis/BillInfo.aspx?s=25RS\&b=SB14\&sbi=y) (Louisiana Legislature) > **Not legal advice.** Verify all statutory references and ingredient list entries against the enrolled bill. Consult Louisiana counsel before relying on this page for compliance. # Manufacturer Control Source: https://docs.closient.com/compliance/manufacturer-control Why a neutral resolver — not brand-owned custom domains — is the correct architecture for satisfying "manufacturer control" requirements under US state ingredient disclosure laws (LA SB 14, TX SB 25) and the EU Digital Product Passport regime (ESPR Regulation 2024/1781). Both US state ingredient disclosure laws and the EU Digital Product Passport regime require that consumer-facing product information be **under manufacturer control**. The two regimes converge on the same architectural pattern: **neutral resolver infrastructure plus manufacturer editorial control over the content**. Custom domains owned by individual brands are an anti-pattern under both regimes — for different but compatible reasons. This page makes the architectural case in detail. It is the rationale behind Closient's posture that *every* customer should use `https://www.closient.com/01/{gtin}` as the printed QR target, and that brand-owned custom domains should be reserved for brand marketing surfaces, not for the regulated disclosure surface. > **Not legal advice.** This is engineering and architectural reasoning informed by reading the statutes, not a regulatory opinion. The interpretation below has not been adjudicated. Consult counsel before relying on this reasoning for compliance positioning. ## What the regimes actually say ### Louisiana SB 14 LA R.S. 40:661(B)(2) (as enacted by Act 463 of 2025): > "...the manufacturer shall ensure that the \[QR code] links to a web page that is under the control of the manufacturer..." The phrase "**under the control of the manufacturer**" is the operative requirement. The statute does not define "control" — it does not say "on a domain owned by the manufacturer," it does not say "self-hosted," it does not say "on infrastructure operated by the manufacturer." The natural reading is **functional editorial control**: the manufacturer determines what is published on that page, can update it, can take it down, and is responsible for its accuracy. ### Texas SB 25 The Texas statute does not contain the explicit "control of the manufacturer" phrasing. It assigns responsibility to the manufacturer for the disclosure but is silent on hosting topology. The Texas case for resolver-based architecture is therefore even simpler: nothing in the statute forecloses it, and the operational case below applies in full. ### EU ESPR / Digital Product Passport [Regulation (EU) 2024/1781](https://eur-lex.europa.eu/eli/reg/2024/1781/oj) — the Ecodesign for Sustainable Products Regulation — creates the Digital Product Passport (DPP) for goods sold in the EU. Two provisions are load-bearing for the architecture argument: **Article 2(32)** — defines a "DPP service provider" as: > "...a natural or legal person... that processes information related to the digital product passport on behalf of an economic operator..." The DPP service provider is explicitly framed as **a separate legal person from the economic operator** (the brand). The regulation contemplates a market of independent service providers — not brand-self-hosted infrastructure. **Article 11** — provides for backup continuity: > "Member States and economic operators shall ensure that the information contained in the digital product passport remains available for the period... including in the event of the insolvency, liquidation or cessation of activity in the Union of the economic operator that placed the product on the market..." In plain terms: **the DPP must survive the brand's bankruptcy**. If the brand goes under, the disclosure must still resolve. A custom domain owned by the brand fails this requirement the moment the registration lapses. ## Why custom domains are the wrong architecture ### 1. Brand insolvency breaks resolution A custom domain — `compliance.examplebrand.com` — is held by the brand. When the brand goes under, the registration lapses, the DNS goes dark, and every QR code printed on every package the brand ever shipped becomes a dead link. The statutory disclosure obligation does not vanish with the brand; the products it sold are still on shelves, still being scanned, still owed a disclosure surface. This is the exact failure mode EU ESPR Article 11 is written to prevent. A neutral resolver operated by an independent service provider survives any single brand's insolvency. The brand's editorial content can be migrated to a successor or archived; the resolution chain stays intact. This is also why LA SB 14's "control of the manufacturer" language should be read as functional editorial control, not infrastructure ownership: the legislature's clear intent is that the disclosure works. An interpretation that requires brand-owned hosting is an interpretation that mandates the disclosure break the day the brand files Chapter 7. That cannot be what the legislature meant. ### 2. One QR per product, not one per regime Brand-owned custom domains do not scale across the copycat wave. If a brand prints `compliance.examplebrand.com/sku123` on the package for Louisiana, and a year later New York passes its own law with different ingredient terminology, the brand has two choices: re-print the package (impossible for products already in distribution) or have one URL satisfy two different state regimes (which the brand has to maintain content for, on the same surface, indefinitely). A neutral resolver inverts the problem. The QR target is stable: `https://www.closient.com/01/{gtin}`. State-specific disclosure variants are routed via [link types](/guides/digital-link-resolution#content-negotiation), locale parameters, or geographic resolution at the resolver layer. The package is printed once; the regulatory routing is reconfigurable indefinitely. ### 3. Independence is a regulatory feature EU ESPR Article 2(32) defines DPP service providers as legally independent from the economic operator on purpose. The European legislature wanted a market of compliance infrastructure providers, not brand-self-attestation. The "service provider as separate legal person" structure protects the consumer (independent custodian, harder to backdate edits) and the regulator (single point of audit per provider, not per-brand). US state ingredient disclosure laws have not yet adopted this framing, but the direction of travel is clearly toward it. The architecture that satisfies LA SB 14 *and* TX SB 25 *and* the EU DPP without rework is the architecture where the resolver is operated by an independent neutral party. That is the architecture Closient offers. ### 4. Operational reality Even setting aside the regulatory case, custom domains for compliance disclosure are operationally bad: * **TLS certificate management**: every brand must maintain a valid certificate on its disclosure subdomain forever; one expiry, one regulatory exposure. * **Uptime monitoring**: a 404 or a timeout on a regulated disclosure URL is a labeling defect, not an availability incident. Brands underinvest in this and regulators don't care. * **DNS / WHOIS provenance**: regulators investigating a misleading disclosure want to know who controls the page. A brand-owned subdomain gives them a WHOIS record; a neutral resolver gives them a single, well-known, audit-friendly intermediary. * **Cross-jurisdiction content**: a single URL hard-coded into the QR cannot carry per-jurisdiction variants without server-side routing. Brands end up either over-disclosing (showing California Prop 65 warnings to Texas consumers) or under-disclosing (missing the LA SB 14 disclosure for shipments into LA). ## What "manufacturer control" actually means in practice Editorial control over the content. The manufacturer: * **Authors** the disclosure page text via the [Closient dashboard](https://www.closient.com/dashboard/) or the [Content API](/api-reference/content) — the same way they author any other product-page surface. * **Owns** the page in the sense that they determine what is published, when it changes, and what is taken down. The resolver does not edit the content; it routes the request. * **Is responsible** for the accuracy of the disclosure under the relevant state statute. Closient's role is custodial, not editorial. This is the same relationship a brand has with any neutral infrastructure provider — a CDN, an email service provider, a cloud storage bucket. The provider operates the infrastructure; the brand controls the content. No one argues that hosting marketing emails on a third-party ESP means the brand has "lost control" of the marketing content; the same logic applies to a compliance disclosure surface hosted on a neutral resolver. ## When a custom URL *is* the right answer The architecture above does not preclude custom URLs. The resolver supports `destination_type: CUSTOM_URL` precisely so that brands with sophisticated existing infrastructure can route the disclosure surface to their own systems. The recommendation in this page is that the **printed QR target on the package** should be the resolver URL, not the custom URL — so that: * Routing decisions are made at the resolver, not baked into the QR. * Multi-jurisdiction support is configuration, not a re-print. * The brand's bankruptcy does not break the resolution chain — the resolver can fall back to a hosted page or to a successor's content at the configuration layer. Use a custom URL when the disclosure content lives natively in a system the brand operates (an existing PIM, a regulated-affairs CMS). Do not use a custom domain *as the QR target* unless your counsel has signed off on the bankruptcy/continuity argument for your specific product line. ## Related * [US State Ingredient Disclosure overview](/compliance/us-state-ingredient-disclosure) * [Louisiana SB 14](/compliance/louisiana-sb14) — the explicit "manufacturer control" phrasing * [Texas SB 25](/compliance/texas-sb25) — the implicit manufacturer-responsibility framing * [GS1 Digital Link Resolution](/guides/digital-link-resolution) — the underlying resolver and content negotiation * [Resolver Rules](/user-guides/resolver-rules) — configuring the routing * [ESPR Regulation (EU) 2024/1781](https://eur-lex.europa.eu/eli/reg/2024/1781/oj) — the EU Digital Product Passport regime > **Not legal advice.** The architectural argument above is engineering reasoning, not a regulatory opinion. Consult counsel before relying on this reasoning for compliance positioning, especially when the question involves cross-border content (EU DPP) or insolvency-continuity scenarios. # Texas SB 25 Source: https://docs.closient.com/compliance/texas-sb25 Texas SB 25 (89th Legislature, Regular Session, 2025) — the "Making Texas Healthy Again" act — requires manufacturers of food products offered for retail sale in Texas to disclose 44 listed ingredients via a scannable code on the package. Labeling rules take effect January 1, 2027. Texas [Senate Bill 25](https://capitol.texas.gov/BillLookup/History.aspx?LegSess=89R\&Bill=SB25), enacted in the 89th Texas Legislature, Regular Session, 2025, is the broader "Making Texas Healthy Again" act; one of its provisions requires manufacturers of food products offered for retail sale in Texas to disclose any of 44 listed ingredients via a scannable code on the package. The statute generally took effect **September 1, 2025**; the labeling provisions are enforceable for products manufactured on or after **January 1, 2027**. > **Not legal advice.** This summary is provided for engineering and operations teams configuring Closient. The statutory text controls. Verify the ingredient list, effective dates, and exemption scope against the [enrolled bill](https://capitol.texas.gov/BillLookup/History.aspx?LegSess=89R\&Bill=SB25) and consult Texas counsel before relying on this page for compliance. ## What the statute requires Under SB 25 (codified at Tex. Health & Safety Code §431.0816 — verify codification against the enrolled text), a food product offered for retail sale in Texas that contains any of the listed ingredients must, on or after the labeling effective date, bear a label that includes: 1. **A scannable code** — URL, QR code, barcode, or other electronic means — on the package, that 2. **Resolves to information identifying the presence of the listed ingredient** for the product The Texas statute is broader in form than LA SB 14 — it permits "URL, QR code, barcode, or other electronic means" rather than specifying QR alone. It does not contain LA SB 14's explicit "control of the manufacturer" phrasing, but the practical effect is the same: the scannable code must resolve to disclosure content the manufacturer is responsible for. See [Manufacturer Control](/compliance/manufacturer-control) for why a neutral resolver still satisfies this in spirit and in practice. ## The 44-ingredient list The Texas list overlaps substantially with [Louisiana SB 14](/compliance/louisiana-sb14) but is not identical. The three ingredients that appear on the Texas list and not on the Louisiana list are highlighted below. > The list below is a working summary for engineering reference. The [enrolled bill text](https://capitol.texas.gov/BillLookup/History.aspx?LegSess=89R\&Bill=SB25) is the controlling source — verify every entry before treating any list as canonical, and re-verify after each legislative session in case of amendments. | # | Ingredient | Category | TX-only | | - | - | - | - | | 1 | Potassium bromate | Dough conditioner | | | 2 | Propylparaben | Preservative | | | 3 | Butylated hydroxyanisole (BHA) | Antioxidant preservative | | | 4 | Butylated hydroxytoluene (BHT) | Antioxidant preservative | | | 5 | Brominated vegetable oil (BVO) | Emulsifier | | | 6 | Red Dye 3 (FD\&C Red No. 3) | Color additive | | | 7 | Red Dye 40 (FD\&C Red No. 40) | Color additive | | | 8 | Yellow Dye 5 (FD\&C Yellow No. 5, tartrazine) | Color additive | | | 9 | Yellow Dye 6 (FD\&C Yellow No. 6) | Color additive | | | 10 | Blue Dye 1 (FD\&C Blue No. 1) | Color additive | | | 11 | Blue Dye 2 (FD\&C Blue No. 2) | Color additive | | | 12 | Green Dye 3 (FD\&C Green No. 3) | Color additive | | | 13 | Citrus Red 2 (FD\&C Citrus Red No. 2) | Color additive | | | 14 | Orange B | Color additive | | | 15 | Acesulfame potassium (Ace-K) | Artificial sweetener | | | 16 | Aspartame | Artificial sweetener | | | 17 | Saccharin | Artificial sweetener | | | 18 | Sucralose | Artificial sweetener | | | 19 | Neotame | Artificial sweetener | | | 20 | Advantame | Artificial sweetener | | | 21 | Bleached flour | Flour treatment | | | 22 | Bromated flour | Flour treatment | | | 23 | **DATEM** (diacetyl tartaric acid ester of monoglycerides) | Dough conditioner | **TX-only** | | 24 | Calcium bromate | Dough conditioner | | | 25 | Potassium aluminum sulfate | Leavening / firming agent | | | 26 | Sodium aluminum sulfate | Leavening agent | | | 27 | Sodium aluminum phosphate | Leavening agent | | | 28 | Aluminum ammonium sulfate | Leavening agent | | | 29 | Diacetyl | Flavoring | | | 30 | Propyl gallate | Antioxidant preservative | | | 31 | TBHQ (tert-butylhydroquinone) | Antioxidant preservative | | | 32 | Olestra | Fat substitute | | | 33 | Partially hydrogenated oils (PHOs) | Fat / trans-fat source | | | 34 | Sodium nitrite | Cured-meat preservative | | | 35 | Sodium nitrate | Cured-meat preservative | | | 36 | Carrageenan | Thickener / emulsifier | | | 37 | Carboxymethylcellulose (CMC) | Emulsifier / thickener | | | 38 | Polysorbate 60 | Emulsifier | | | 39 | Polysorbate 80 | Emulsifier | | | 40 | Sodium benzoate | Preservative | | | 41 | Potassium benzoate | Preservative | | | 42 | **Ficin** | Proteolytic enzyme | **TX-only** | | 43 | **Titanium dioxide** | Whitener / color additive | **TX-only** (banned in EU food use) | | 44 | Sulfites (sulfur dioxide / sodium sulfite / sodium bisulfite / potassium metabisulfite) | Preservative | | The three Texas-specific entries are flagged inline. Note that Texas omits a small number of entries that appear on Louisiana's list (notably azodicarbonamide); a brand operating in both states should not assume one list is a superset of the other. ## Effective dates | Event | Date | | - | - | | SB 25 signed into law | June 2025 | | Statute generally effective | September 1, 2025 | | Labeling requirements enforceable for products manufactured on or after | **January 1, 2027** | The Texas labeling deadline is **12 months earlier than Louisiana's** (Jan 1, 2027 vs Jan 1, 2028). Brands shipping to both states should plan to be label-compliant for Texas first; once the resolver-rule wiring is in place for TX, extending to LA is configuration, not engineering. ## Exemptions The Texas exemptions track LA SB 14 with one notable addition (USDA-regulated meat and poultry, where federal pre-emption applies): * **Drugs** regulated under the federal Food, Drug, and Cosmetic Act * **Dietary supplements** regulated as such under federal law * **Alcoholic beverages** regulated by the TTB / Texas Alcoholic Beverage Commission * **Retail-prepared food** (food prepared and offered for immediate consumption at retail) * **Medical foods** as defined under federal law * **USDA-regulated meat and poultry** (federal pre-emption under the Federal Meat Inspection Act / Poultry Products Inspection Act) Verify the exemption scope against the enrolled bill, particularly around the line between "food product" and "dietary supplement" — Texas case law on this distinction may differ from Louisiana's. ## Enforcement Enforcement authority sits with the Texas Department of State Health Services and is integrated with the broader Texas food-labeling enforcement framework. Penalties follow the statutory schedule for food-labeling violations under Tex. Health & Safety Code Chapter 431. Consult Texas counsel for the current penalty structure and any rulemaking from DSHS implementing the SB 25 provisions. There is no federal pre-emption argument for the disclosure obligation itself, though specific listed ingredients regulated under USDA jurisdiction (meat and poultry products) are carved out as above. ## Configuring Closient for TX SB 25 The configuration is identical to LA SB 14 — the disclosure surface is the same `gs1:ingredientsInfo` link type, the QR target is the same `https://www.closient.com/01/{gtin}` resolver URL. The Texas statute does not require a separate safety-information surface the way Louisiana does, so `gs1:safetyInfo` is optional for Texas-only compliance but recommended if you are shipping to both states. ```bash theme={null} curl -X POST https://www.closient.com/v1/products/{gtin}/resolver-rules \ -H "X-API-Key: $CLOSIENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "link_type": "gs1:ingredientsInfo", "destination_type": "HOSTED_PAGE", "hosted_page_id": "page_ingredients_01HXYZ...", "scope": "PRODUCT" }' ``` Texas permits "URL, QR code, barcode, or other electronic means" — if your packaging already carries a non-QR scannable code (a UPC barcode that resolves through your retailer's app, for instance), that may also satisfy the statute. The safest interpretation, and the one that scales across the copycat wave, is a QR code targeting the resolver URL; see [GS1 Digital Link Resolution](/guides/digital-link-resolution) for the full URI grammar. ## Related * [US State Ingredient Disclosure overview](/compliance/us-state-ingredient-disclosure) * [Louisiana SB 14](/compliance/louisiana-sb14) — the sibling Louisiana law (overlapping list, later effective date, explicit "manufacturer control" requirement) * [Manufacturer Control](/compliance/manufacturer-control) — architectural rationale for the resolver-based pattern * [TX SB 25 bill page](https://capitol.texas.gov/BillLookup/History.aspx?LegSess=89R\&Bill=SB25) (Texas Legislature) > **Not legal advice.** Verify all statutory references and ingredient list entries against the enrolled bill. Consult Texas counsel before relying on this page for compliance. # US State Ingredient Disclosure Source: https://docs.closient.com/compliance/us-state-ingredient-disclosure How Closient surfaces per-product ingredient and safety information to satisfy US state ingredient disclosure laws (Louisiana SB 14, Texas SB 25, and the copycat wave to follow), using GS1 Digital Link link types as the canonical substrate. US states are starting to mandate per-product ingredient and safety disclosure tied to scannable codes on the package. Louisiana ([SB 14, Act 463 of 2025](https://legis.la.gov/legis/ViewDocument.aspx?d=1426851)) and Texas ([SB 25 of 2025](https://capitol.texas.gov/BillLookup/History.aspx?LegSess=89R\&Bill=SB25)) are the first two; California, New York, Illinois, and others have similar bills in flight. The pattern is the same in every draft: 1. A defined list of ingredients (preservatives, dyes, additives) that triggers the obligation if present in a covered food product. 2. A requirement that the package carry a scannable code (QR or other machine-readable identifier). 3. A requirement that the code resolve to a web page disclosing the regulated ingredient and, in most drafts, accompanying safety information. Closient is built for this pattern. Every product in the catalog already has a GS1 Digital Link resolver entry; surfacing the disclosure is a matter of registering one extra [link type](/guides/digital-link-resolution) (`gs1:ingredientsInfo`, `gs1:safetyInfo`) and pointing it at content the brand controls. No new domain, no new infrastructure, no migration. This section explains how Closient satisfies each law, why our resolver-based architecture is the right substrate for the copycat wave, and what you as a brand operator need to do to flip disclosure on for a product. > **Not legal advice.** This documentation is provided for engineering and operations teams implementing against Closient. It is not a substitute for review by qualified counsel licensed in your jurisdiction. Statutory text, effective dates, and enforcement posture change; verify against the official bill text before relying on any specific claim below. ## The two laws at a glance | Law | Bill | Effective | Covered products | Disclosure surface | | - | - | - | - | - | | **Louisiana SB 14** ([Act 463 of 2025](https://legis.la.gov/legis/ViewDocument.aspx?d=1426851)) | LA SB 14 | Labeling rules: **January 1, 2028** | Food and beverage manufactured on or after the effective date, sold for human consumption in Louisiana, containing one or more of 44 listed ingredients | QR code on the package linking to a web page under manufacturer control | | **Texas SB 25** ([89R SB 25](https://capitol.texas.gov/BillLookup/History.aspx?LegSess=89R\&Bill=SB25)) | TX SB 25 | Labeling rules: **January 1, 2027** (statute effective Sep 1, 2025) | Food product offered for retail sale in Texas, containing one or more of 44 listed ingredients | URL, QR code, barcode, or other electronic means linking to required disclosure | Both laws share most of their ingredient list. The Texas list contains three ingredients that the Louisiana list does not: **DATEM** (diacetyl tartaric acid ester of monoglycerides), **ficin**, and **titanium dioxide**. The Louisiana list contains three ingredients the Texas list does not (azodicarbonamide variants and a handful of color additives — see the per-law pages for the canonical text). Treat them as overlapping-but-not-identical and configure disclosure per ingredient, not per law. | | LA SB 14 | TX SB 25 | | - | - | - | | Detail page | [Louisiana SB 14](/compliance/louisiana-sb14) | [Texas SB 25](/compliance/texas-sb25) | | Ingredients in list | 44 | 44 | | Effective for labels | Jan 1, 2028 | Jan 1, 2027 | | Enforcement | Attorney General (LA R.S. 51:1407 et seq. — unfair trade practice) | Department of State Health Services | | Exemptions | Drugs, dietary supplements, alcoholic beverages, retail-prepared food, medical foods | Drugs, dietary supplements, alcoholic beverages, retail-prepared food, medical foods, USDA-regulated meat and poultry | The exemption pattern is consistent across both statutes and across the bills under debate in other states: ingredient disclosure laws cover **packaged food and beverage sold to consumers** and explicitly leave drugs, supplements, alcoholic beverages, meat and poultry (where USDA pre-emption applies), retail-prepared food, and medical foods alone. If your catalog is all-supplement or all-alcohol, this section does not apply to you. ## How Closient satisfies the disclosure obligation Three building blocks, all of which Closient ships out of the box: ### 1. GS1 Digital Link resolver as the scannable target Every product in Closient already has a resolver URL of the form: ``` https://www.closient.com/01/{gtin} ``` That URL is what we recommend printing as the QR code on the package. It is unambiguous, GS1-standard, and free for the brand to use indefinitely. See [GS1 Digital Link Resolution](/guides/digital-link-resolution) for the full URI grammar and content negotiation behavior. ### 2. Link types as the disclosure routing primitive The GS1 Web Vocabulary defines link types — semantic tags that say *what* a particular destination URL contains. The two relevant link types for state ingredient disclosure are: | Link type | GS1 IRI | Purpose | | - | - | - | | `gs1:ingredientsInfo` | `https://gs1.org/voc/ingredientsInfo` | The "what is in this product" disclosure page | | `gs1:safetyInfo` | `https://gs1.org/voc/safetyInfo` | The accompanying safety/warning information page | Register one entry per link type per product, and the resolver routes the scan to the right page based on the `linkType` query parameter or content negotiation. The Louisiana and Texas statutes both specify that the linked page must surface the regulated ingredient and (Louisiana) accompanying safety information; mapping each obligation to a distinct link type keeps the semantics clean and lets the brand update the safety surface independently of the ingredient surface. See the [GS1 link type vocabulary](https://ref.gs1.org/voc/?show=linktypes) for the full registry. ### 3. Manufacturer-controlled content via resolver rules LA SB 14 R.S. 40:661(B)(2) requires that the QR resolve to "a web page that is under the control of the manufacturer." The Texas statute uses similar manufacturer-control language. **Closient interprets this as functional editorial control, not infrastructure ownership** — see [Manufacturer Control](/compliance/manufacturer-control) for the full architectural rationale, including why this reading is correct under both the US state statutes and the EU Digital Product Passport regime. The practical mechanism: every brand has a [resolver rule](/user-guides/resolver-rules) configuration in their Closient dashboard. To flip ingredient disclosure on for a product, add a rule that maps the `gs1:ingredientsInfo` link type to a page the brand has authored — either a [Closient-hosted page](/user-guides/managing-products) (the default; brand edits the content via the dashboard) or a [custom URL](/user-guides/resolver-rules) on a domain the brand operates. ## Configuration example Enabling LA SB 14 / TX SB 25 disclosure for a product is a single resolver-rule addition. Via the Products API: ```bash theme={null} curl -X POST https://www.closient.com/v1/products/{gtin}/resolver-rules \ -H "X-API-Key: $CLOSIENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "link_type": "gs1:ingredientsInfo", "destination_type": "HOSTED_PAGE", "hosted_page_id": "page_01HXYZ...", "scope": "PRODUCT" }' ``` The `hosted_page_id` references the disclosure page the brand has authored in the Closient dashboard. For brands who already have an ingredient-disclosure surface on their own infrastructure, swap `destination_type` to `CUSTOM_URL` and supply the target URL plus a [fallback policy](/guides/digital-link-resolution) — the resolver monitors custom URL health and can fall back to the Closient-hosted page automatically if your domain becomes unreachable. Repeat for `gs1:safetyInfo` to surface the accompanying safety information page that LA SB 14 R.S. 40:661(B)(3) requires. ## Why this approach scales to copycat laws The copycat wave that LA SB 14 and TX SB 25 are kicking off will follow the same pattern: a state-specific ingredient list, an effective date, an exemption carve-out, and a "QR/scannable code on the package linking to manufacturer-controlled disclosure" mechanism. Building one disclosure surface per state is the wrong architecture — the brand ends up with five QR codes on a single product and a different domain for each. Closient's resolver inverts the problem: **one QR code per product, one resolver endpoint, content routed by link type and locale**. When a new state law lands, you do not re-print the package. You register one additional link type or one additional locale variant and the existing QR resolves correctly in that jurisdiction. The same architecture is what makes Closient the natural fit for the EU [Digital Product Passport](https://eur-lex.europa.eu/eli/reg/2024/1781/oj) — see [Manufacturer Control](/compliance/manufacturer-control) for the convergence argument. ## Related * [Louisiana SB 14](/compliance/louisiana-sb14) — per-state detail, 44-ingredient list, statutory references * [Texas SB 25](/compliance/texas-sb25) — per-state detail, 44-ingredient list, statutory references * [Manufacturer Control](/compliance/manufacturer-control) — why a neutral resolver satisfies the "manufacturer control" requirement * [GS1 Digital Link Resolution](/guides/digital-link-resolution) — the underlying resolver, URI grammar, content negotiation * [Resolver Rules](/user-guides/resolver-rules) — configuring per-product, per-link-type destinations * [AB 660 / Date Labels](/guides/ab-660-date-labels) — the California analog for date-label phrasing (different mechanism, same compliance-as-config pattern) > **Not legal advice.** Verify all statutory references, effective dates, and ingredient lists against the official bill text. Consult counsel licensed in the relevant jurisdiction before relying on the configuration described above for regulatory compliance. # Analytics Retention & Privacy Source: https://docs.closient.com/concepts/analytics-retention How long Closient keeps scan analytics, what data is captured, and how privacy is preserved by design. Closient's analytics pipeline is built to give you long-term visibility into how your products are scanned while keeping the underlying data **non-personal by design**. This page describes what's captured, how long it's kept, and the privacy stance behind those choices. ## Retention at a glance Analytics data is stored in two tiers: | Tier | What it is | Retention | Used by | | - | - | - | - | | **Raw scan events** | One row per scan (GTIN, timestamp, derived country, device class, bot flag, batch/lot if encoded) | **90 days** | Detailed drill-down, recent debugging, export of recent activity | | **Daily aggregates** | Per-product, per-day rollups of scan counts plus country / device / hour / AI-traffic breakdowns | **Indefinite** | Long-term trend lines, year-over-year comparisons, brand analytics endpoints | The daily rollup runs nightly and writes a `ScanDailySummary` row for each product that had scans that day. Once a day has been rolled up, the raw events for that day remain queryable for **90 days**, then a daily purge task deletes them. The aggregate row stays forever. **Practical impact:** anything you can already see in the [Brand Analytics scans endpoint](/api-reference/dashboard) — total scans, billable vs bot, country breakdown, device breakdown, daily volume timeline — is computed from the indefinitely-retained aggregate. You do **not** lose visibility into history when raw events age out. The raw-event purge is safety-railed: if the daily rollup hasn't caught up to the cutoff date yet (e.g. a Celery outage delayed it), the purge task aborts rather than delete raw events the aggregator never summarized. ## What is **not** captured Closient analytics are designed so that no individual consumer can be re-identified from a scan record. Specifically: * **No IP address is stored.** The IP from the scan request is used only to derive a coarse country code (via MaxMind GeoLite2), then discarded before the row is written. * **No browser or device fingerprints.** No canvas hashes, no WebGL probes, no font enumeration, no audio fingerprints — none of the techniques common to ad-tech stacks. * **No cookies, no persistent identifiers, no cross-site tracking.** A scan is recorded as an isolated event, not as part of a user journey. * **No precise geolocation.** Country is the most precise geo dimension stored. Latitude/longitude from scanner apps is never persisted to the analytics tables. * **No user agent string.** Only a derived device class (`mobile`, `tablet`, `desktop`, `bot`) and a coarse user-agent family are kept. Bot traffic is detected and counted separately (`bot_scans`) so you can see the volume without it polluting your billable-scan totals. ## GDPR alignment Because Closient analytics events contain no personal data — no IP, no identifier, no fingerprint, only coarse derived dimensions — they are **non-personal data** within the meaning of the GDPR. There is no data-subject record to look up, rectify, or erase: there is no subject in the data in the first place. We document retention windows here anyway. Even though GDPR's storage-limitation principle does not strictly apply to non-personal data, treating retention as an explicit contract is good hygiene and gives you a clear answer when your own privacy reviewers ask. For the consumer-facing privacy policy, cookie policy, and terms of service, see the canonical legal pages: * [Privacy Policy](https://www.closient.com/privacy/) * [Cookie Policy](https://www.closient.com/cookies/) * [Terms of Service](https://www.closient.com/terms/) Each policy is versioned, and every published version stays available from its page's version history — a consent record always references the exact version that was in effect when it was given. We do not maintain a second copy of the policy text here. ## How retention is configured today The raw-event retention window is currently a **single global value** (90 days) applied to every organization. Tier-aware retention — where Free / Paid / Enterprise plans get different raw-event windows — is on the roadmap but not yet live. When it ships, this page will be updated to show the per-tier table. If you have a regulatory or contractual requirement to retain raw events for longer than 90 days, contact [support@closient.com](mailto:support@closient.com). ## Roadmap items not yet shipped The following parts of the long-term analytics retention design are still in flight. They are listed here for transparency; this page will be updated when each lands so what you read here always matches what is running in production. * **Hourly rollups + HyperLogLog uniques.** A second aggregate tier between raw events and daily rollups, plus privacy-preserving unique-visitor counts via HyperLogLog sketches (no identifiers stored — only a fixed-size probabilistic structure that approximates the unique count). * **Tier-aware retention windows.** Per-subscription-tier raw-event retention (Free / Paid / Enterprise). * **Page-visit retention.** Equivalent retention tiers for `PageVisitEvent` — the hosted-page render analogue of scan events — once the rollup pipeline for those lands. ## Related Day-to-day guide to reading scan data and search performance. Brand-analytics endpoints that query the indefinitely-retained aggregates. # Embeddable Widgets — Data Processing Source: https://docs.closient.com/concepts/store-locator-widget-privacy Every network request Closient's embeddable widgets make, what each one carries, and who receives it. Closient's embeds run our JavaScript on **your** pages. That makes it your visitors' data, disclosed under your privacy notice — so you need to know exactly what they send and where. This page is the factual inventory to work from when you fill in your own notice or a data processing agreement. There are three embeds. Most of the page is written in terms of the **store locator** (`store-locator.js`), which shows the locations of a single organization; the other two get a section each for what differs. * The [brand product locator](#brand-product-locator) (`brand-locator.js`) shows which nearby stores carry a given brand's products. It behaves identically to the store locator on every question here, calling a different set of Closient endpoints. * The [product information panel](#product-information-panel) (`pip-panel.js`) renders compliance data for one GTIN on a retailer's product detail page. It is materially narrower than the two locators: one request, one host, no location, no visitor input, no cookies. This page describes system behaviour, not legal terms. Closient's own privacy policy and terms are published separately at [closient.com](https://www.closient.com) and govern our side of the relationship. ## Requests the widget makes Everything the widget loads or calls, in the order a visitor triggers it. | Request | Sent to | Carries | When | | - | - | - | - | | Widget script + styles + map library | **Closient** | Standard HTTP request metadata (IP, user agent, referring page) | On page load | | `POST /locations/api/v1/public/widget-events` | **Closient** | Event name, your publishable key prefix, referring URL, and — on a search — the text the visitor typed | On load, search, and click-through | | `GET /locations/api/v1/public/geo` | **Closient** | Nothing beyond the request itself | Only if the visitor denies or ignores the browser location prompt | | `GET /locations/api/v1/public/geocode` | **Closient** | The address, city, or postal code the visitor typed | Only when the visitor searches by address | | `GET /locations/api/v1/public/locations` | **Closient** | The search coordinates and radius | Once a location is established | | Map tiles | **OpenStreetMap Foundation** | Standard HTTP request metadata, plus the map area being viewed | Only once a map renders, and only if the map is switched on | **Closient is the only party that receives visitor data, with one exception: map tiles** — and that exception is one you can switch off, with [`data-map="off"`](#reducing-third-party-exposure-to-zero). Tile images are fetched by the visitor's browser directly from [OpenStreetMap](https://www.openstreetmap.org), so while the map is on, the OpenStreetMap Foundation sees the visitor's IP address and which map area they are looking at. Under their [tile usage policy](https://operations.osmfoundation.org/policies/tiles/) they also publish anonymised, aggregated usage data that includes which websites use the service. No other third party is contacted — the map library is bundled into the widget script, and both location lookups run on Closient's servers rather than in the visitor's browser. **Why Closient does not proxy tiles for you.** We proxy the geocoder, so the obvious question is why not the map as well. The OpenStreetMap tile usage policy forbids it: §3.4 rules out "tunnel\[ling] all clients behind a single, anonymous identity", which is precisely the property that would make a proxy private, and §5 says the Foundation "generally do\[es] not recommend" a caching proxy in front of their tiles. A proxy that satisfied the policy would still disclose your visitors; one that protected them would breach it. The honest options are therefore a tile source you hold your own agreement with, or no map — both below. ## How location is determined The widget tries three sources, in order, and stops at the first that works. The browser's own location prompt. Precise, and shown to the visitor as an explicit permission request by their browser. Closient receives the resulting coordinates but never the underlying device signals. If the visitor denies or ignores the prompt, the widget asks Closient for the approximate location our edge network already derives from the connection. This is city-level at best and often less. It happens server-side: no third-party geolocation service is involved, and the visitor's IP address is not stored or returned. If neither is available, the visitor types a place. The text is sent to Closient, which resolves it to coordinates server-side and caches the result. The visitor's browser never contacts the geocoding provider, so the provider sees Closient's servers rather than your visitors. ## Cookies and storage **The widget sets no cookies and writes nothing to `localStorage`, `sessionStorage`, or IndexedDB.** It holds state in memory for the life of the page only. Analytics events are attributed to your publishable key, not to a visitor identifier — there is no cross-site or cross-session tracking, and consequently nothing for a cookie banner to gate. Coordinates from the browser's location prompt are used for the search that requested them and are not persisted. ## What Closient retains | Data | Retained | | - | - | | Widget analytics events (load, search, click) | Written to application logs for usage reporting. Not stored in a database and not linked to a visitor identity. | | Search text | Appears in those same logs, truncated. Geocoded results are cached by search text, not by visitor. | | Search coordinates | Used to answer the request. Not persisted. | | IP addresses | Not stored by the widget endpoints. | ## Reducing third-party exposure to zero If your privacy posture rules out any third-party request, the map is the only thing standing in the way — and `data-map="off"` removes it: ```html theme={null} ``` Every row in the request table above then points at Closient, and no map tile is requested. The widget degrades cleanly: the store list, search, distances, and click-throughs all work without a map, and the list expands to fill the space the map would have taken. The same applies to the iframe embed, via a query parameter: ``` https://www.closient.com/widget/store-locator/?key=cpk_live_xxx&map=off ``` ### Keeping a map, on your own terms If you already hold an agreement with a tile provider — MapTiler, Stadia Maps, your own tile server — point the widget at it with `data-tile-url` and give the provider's required attribution in `data-tile-attribution`: ```html theme={null} ``` Visitors then reach a processor you have listed, rather than one you have not. `data-tile-attribution` is required whenever `data-tile-url` is set: rather than guess an attribution or quietly fall back to OpenStreetMap — the host you were configuring your way off — the widget switches the map off and logs the reason to the browser console. Both attributes are script-embed only; the iframe embed accepts `map=off` but not a custom tile URL. ## Configuration reference The embed accepts these attributes. Only `data-map` and `data-tile-url` change what data is processed, and both only by removing or redirecting the map tile request. | Attribute | Purpose | | - | - | | `data-api-key` | Your publishable key (`cpk_…`). Safe to expose — it is read-only and scoped to your organization's locations. | | `data-api-base` | Closient origin the widget calls. Defaults to wherever the script was served from. | | `data-theme` | `light` or `dark`. | | `data-radius` | Search radius. | | `data-unit` | `km` or `mi`. | | `data-map` | `on` (default) or `off`. `off` renders the locator with no map and makes the embed entirely first-party. Any other value is treated as `off`, with a console warning — a typo should not leave a third-party request switched on. | | `data-tile-url` | Tile source for the map, as a Leaflet `{z}/{x}/{y}` template. Defaults to OpenStreetMap. Requires `data-tile-attribution`. | | `data-tile-attribution` | Attribution shown on the map for a custom `data-tile-url`. Required whenever that attribute is set. | ## Brand product locator `brand-locator.js` answers a different question — *which shops near me stock this brand* — and is embedded by the brand, or by a merchant on their own storefront, rather than by the organization that owns the stores: ```html theme={null} ``` Everything above about location, cookies, storage, retention, and the map applies to it unchanged, including `data-map="off"` and `data-tile-url`. Three things differ. **It carries no API key.** The brand id in the embed is the whole identity, and it is already public — the same id appears in Closient's own public brand pages. The endpoints it calls take no authentication at all, so there is no credential to leak from a page you do not control. **It calls the brand endpoints, not the store-locator ones.** Same origin, same processor, different paths: | Request | Sent to | Carries | When | | - | - | - | - | | Widget script + styles + map library | **Closient** | Standard HTTP request metadata (IP, user agent, referring page) | On page load | | `GET /brands/api/v1/public/brands/{brand_id}/catalog` | **Closient** | Nothing beyond the request itself | On load, before any location is requested | | `POST /brands/api/v1/public/widget-events` | **Closient** | Event name, the brand id, referring URL, and — on a search — the text the visitor typed | On load, search, filter change, and click-through | | `GET /brands/api/v1/public/geo` | **Closient** | Nothing beyond the request itself | Only if the visitor denies or ignores the browser location prompt | | `GET /brands/api/v1/public/geocode` | **Closient** | The address, city, or postal code the visitor typed | Only when the visitor searches by address | | `GET /brands/api/v1/public/brands/{brand_id}/locator` | **Closient** | The search coordinates, radius, and the selected product filter if any | Once a location is established | | Map tiles | **OpenStreetMap Foundation** | Standard HTTP request metadata, plus the map area being viewed | Only once a map renders, and only if the map is switched on | **It asks nothing of the visitor when there is nothing to show.** The catalog call above runs first, before the browser's location prompt, precisely so the widget can find out whether the brand is stocked anywhere at all. If it is not, the widget renders nothing and never requests a location — a permission prompt whose answer could not be used is not one worth showing. ### Configuration reference Identical to the table above, except that `data-api-key` is replaced by: | Attribute | Purpose | | - | - | | `data-brand-id` | The Closient brand id to locate. Required — without it the widget renders nothing. Public, not a credential. | ## Product information panel `pip-panel.js` is the third embed and the only one that is not a locator. It renders recall status, allergens, certifications and the printed ingredient label for a single GTIN, and it is embedded by a retailer or marketplace on their own product detail pages: ```html theme={null} ``` Integration details are in the [embed guide](/guides/pip-embed). For the purposes of this page there is one thing worth saying plainly: **it is the narrowest of the three embeds on every question here.** **Every request it makes goes to Closient.** | Request | Sent to | Carries | When | | - | - | - | - | | Widget script + styles | **Closient** (`www.closient.com`) | Standard HTTP request metadata (IP, user agent, referring page) | On page load | | `GET /products/api/v1/public/pip/{gtin}` | **Closient** (`www.closient.com`) | The GTIN in the embed, and nothing else beyond the request itself | Once, on load | | Product image | **Closient's media CDN** | Standard HTTP request metadata | Only when the product has an image | The product image is worth calling out because it is the one request that does **not** go to `www.closient.com`. Closient serves product media from a separate storage domain, so a visitor's browser contacts that host too. It is still Closient — not a third party, not an image host the brand chose — but it is a second origin, which matters if you are writing a Content Security Policy. The exact hostname is whatever comes back in the `image_url` field of the response above; read it once for a product you carry and put that origin in your `img-src`. Beyond those three, nothing. No map, so no tiles and no `data-map` to switch off; no location, so no geolocation prompt, no IP geolocation call and no geocoder; no visitor input of any kind, so nothing a visitor types is ever transmitted. **It sends no cookies and sets none.** The fetch is made with `credentials: 'omit'`, and the endpoint behind it is anonymous by construction — it reads no session, and a response that varied by visitor could not be cached the way this one is. Closient cannot recognise a visitor across your pages, or between your site and anyone else's. **It fires no analytics from the browser.** Unlike the two locators, the panel posts no widget-event. Closient counts panel renders server-side from the request it already receives, in aggregate and tagged distinctly from real QR scans, so nothing about an individual visitor is recorded and a brand's scan numbers are not inflated by embed traffic. **It stores nothing on the device.** No cookie, no `localStorage`, no `sessionStorage`, no IndexedDB. ### Configuration reference | Attribute | Purpose | | - | - | | `data-gtin` | The GTIN to render. Required — without it the panel renders nothing and makes no request. Public, not a credential. | | `data-theme` | `light` (default), `dark`, or `auto`. `auto` reads the visitor's OS colour-scheme preference locally; the preference is never transmitted. | | `data-branding` | `on` (default) or `off`. Controls only whether the "Powered by Closient" mark renders. | # Errors Source: https://docs.closient.com/errors Standard error responses across all Closient APIs. All APIs return errors in a consistent envelope format. ## Error Format ```json theme={null} { "error": { "code": "NOT_FOUND", "message": "The requested resource was not found.", "status": 404, "details": null } } ``` ## Error Codes | Status | Code | Description | | - | - | - | | 400 | `BAD_REQUEST` | The request body or parameters are invalid | | 401 | `UNAUTHORIZED` | Missing or invalid API key | | 403 | `FORBIDDEN` | Valid key but insufficient permissions | | 404 | `NOT_FOUND` | Resource does not exist | | 409 | `CONFLICT` | Resource already exists or state conflict | | 422 | `VALIDATION_ERROR` | Request body failed validation | | 429 | `RATE_LIMITED` | Too many requests — see [Rate Limits](/rate-limits) | | 500 | `INTERNAL_ERROR` | Unexpected server error | ## Validation Errors `422 VALIDATION_ERROR` responses include field-level details: ```json theme={null} { "error": { "code": "VALIDATION_ERROR", "message": "Invalid input.", "status": 422, "details": [ { "field": "gtin", "message": "GTIN must be 8, 12, 13, or 14 digits." } ] } } ``` ## Best Practices * **Check the `code` field** for programmatic error handling, not the `message` * **Log the full error response** for debugging * **Implement retry logic** with exponential backoff for `429` and `5xx` errors * **Do not retry** `4xx` errors (except `429`) — they indicate a client issue ## Domain-specific 422 responses Some endpoints return `422` with a domain-specific message identifying which input field failed which rule. See: * [AB 660 / Date Labels](/guides/ab-660-date-labels) — the Products API returns `422` when `product_name`, `description`, `net_content`, or `usage_instructions` contains a phrase banned by California AB 660 (e.g. `"sell by"`, `"best before"`, `"expires on"`). # First Steps Source: https://docs.closient.com/first-steps Just signed up? Here's how to get started with Closient in 10 minutes. Welcome to Closient. This guide walks you through the essential setup steps so you can start managing products, resolving barcodes, and connecting with consumers right away. ## 1. Create Your Organization Your organization is the home for your products, team members, and settings. Navigate to **Settings > Organization** in your [Dashboard](https://www.closient.com/dashboard/). Set your organization name, country, and whether this organization represents a brand (toggle **Is Brand** if so). Go to **Settings > Members** and invite colleagues. See [Team Management](/user-guides/team-management) for role details. ## 2. Add Your First Product Click **Products** in the Dashboard sidebar. Enter the GTIN (barcode number), product name, and brand. You can use any format — GTIN-8, UPC-12, EAN-13, or GTIN-14. Add a description, product image, category, and any other relevant data. The more complete the listing, the better it performs in search. You can also bulk import products via CSV or sync from a connected POS system. See [Managing Products](/user-guides/managing-products) for details. ## 3. Claim Your Brand If your organization is a brand, claiming establishes your authority over your product data. Navigate to **Brands** in the Dashboard. Search for your brand name and click **Claim Brand**. Verification options include GS1 Company Prefix, domain verification, or manual review. Once claimed, your product data takes priority over other sources. See [Claiming Your Brand](/user-guides/claiming-your-brand) for the full process. ## 4. Set Up Your First Resolver Rule If you want your product barcodes (QR codes) to direct consumers somewhere specific: Open a product and toggle **Resolver Enabled** in the settings panel. Go to **Resolver > Rules** and create your first rule. Start with an **Organization**-scoped rule pointing to your website as a catch-all. See [Setting Up Resolver Rules](/user-guides/resolver-rules) for time-based routing, scope hierarchy, and advanced configuration. ## 5. Generate an API Key (Developers) If you're integrating via API: Navigate to **Settings > API Keys**. Click **Create API Key**. Choose `csb` (Business) for server-side access or `cpk` (Publishable) for client-side widgets. See the [Quickstart](/quickstart) for working code examples. ## What's Next Add, edit, import, and organize your product catalog. Sync inventory from Shopify, Square, Clover, or Lightspeed. Track scans, search impressions, and product performance. Make your first API request with curl or Python. # AB 660 / Date Labels Source: https://docs.closient.com/guides/ab-660-date-labels How Closient renders GS1 date Application Identifiers (15/16/17/7003) as consumer-facing strings under California AB 660, and how the Products API enforces banned-phrase validation. California [AB 660](https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202320240AB660) takes effect **July 1, 2026**. It is the first US law to mandate standardized date labeling on packaged food sold in California: only two consumer-facing phrases are permitted, and a long list of legacy phrases ("Sell By", "Best Before", "Expires On", etc.) is banned from consumer surfaces. See the [CDFA implementation page](https://www.cdfa.ca.gov/is/foodrecovery/fooddatelabeling/) for the regulator's overview. This page documents the rendering contract Closient applies on resolver destination pages and via the SDK helper, the registration-API validation that rejects banned phrases on input, and the exemptions you should know about. > Other states (NJ, IL, MA, SC) have similar bills in flight. We treat California as the floor and assume other states will follow — the contract below is designed to remain valid as additional states adopt the same phrasing. ## GS1 AI to consumer phrase mapping The GS1 General Specifications define several Application Identifiers (AIs) for date data. Under AB 660, only three of them may be rendered to consumers, and each maps to a specific approved phrase. AI (16) "Sell By" remains legal as **encoded/coded data only** and is never rendered on a consumer-facing surface. | AI | GS1 meaning | Consumer phrase (default) | Consumer phrase (freezable) | Audience | | - | - | - | - | - | | `15` | Best Before Date | `Best if Used By {date}` | `Best if Used or Frozen By {date}` | Consumer (quality) | | `16` | Sell By Date | **Never rendered** | **Never rendered** | Business only — encoded data | | `17` | Use By Date | `Use By {date}` | `Use or Freeze By {date}` | Consumer (safety) | | `7003` | Expiration Date/Time | `Use By {date} {time}` | `Use or Freeze By {date} {time}` | Consumer (regulated) | Dates render as ISO 8601 short form (`YYYY-MM-DD`); datetimes render as `YYYY-MM-DD HH:MM`. AB 660 does not prescribe a date format, so we use the safest unambiguous default for a multinational catalog. Brands rendering on physical packaging will typically reformat in their own print pipeline — the rendered string here is for the resolver destination page and the forthcoming SDK helper. The longer "or Frozen" / "or Freeze" phrasing is permitted by AB 660 for freezable products and is opt-in via the `freezable` flag in the SDK helper. ### What happens to AI (16) "Sell By" AI (16) remains a perfectly valid GS1 AI and is still useful internally for retail rotation, shrink management, and traceability surfaces. Closient accepts AI (16) on the encoded data layer (linkset, EPCIS, master-data integrations) and continues to surface it on business and regulator link types. It is **stripped from any response payload targeted at a consumer audience**, and the SDK `format_date_label` helper returns `null` when called with AI (16) so a brand who accidentally pipes it to a consumer-facing UI gets nothing, not a banned string. ## Banned-phrase validation on the Products API To stop banned date-label strings from entering the catalog in the first place, the Products API scans free-text fields on registration and update. Submitting a banned phrase returns **HTTP 422** with the offending field name and the matched text. ### Which fields are scanned * `POST /v1/products` — scans `product_name`, `description`, `net_content`. * `PATCH /v1/products/{gtin}` — scans `product_name`, `description`, `net_content`, `usage_instructions` (only the fields the caller actually sends). ### Banned phrases (case-insensitive, word-boundary anchored) `sell by`, `sell-by`, `best before`, `best-before`, `best by`, `best-by`, `expires on`, `expires by`, `expiration date`, `freshest by`, `freshest-by`, `enjoy by`, `enjoy-by`, `fresh until`, `good through`, `good-through`, `good til`, `good till`, `guaranteed fresh`, `use before`, `use-before`, `display until`, `pull date`, `purge date`. The list is illustrative, not exhaustive — AB 660 bans any phrase that conveys the same meaning. We catch the high-volume offenders here and rely on human review of edge cases. The match is **case-insensitive** and respects **word boundaries on both sides**, so brand names like `"Sellby Sushi Co."` and `"Bestseller Bypass"` pass cleanly. Hyphens and whitespace are treated interchangeably inside the phrase: `sell by`, `sell-by`, and `sell by` all match. ### Example 422 response A banned phrase in `description` on `POST /v1/products`: ```json theme={null} { "detail": "Field 'description' contains banned date-label phrase 'sell by' (California AB 660). Use one of the approved phrases ('Best if Used By' or 'Use By'), or remove the date label from this field." } ``` The error message identifies the field and the matched substring so you can correct the input and retry. See [Errors](/errors) for the standard error envelope and best practices for handling 422 responses. ### Recommended fix * Remove the date label from free-text fields entirely. Date information belongs on its own structured AI field (15/17/7003), not embedded in `description` or `product_name`. * If you must include a human-readable date in free text (rare), use one of the approved phrases: `Best if Used By {date}` or `Use By {date}`. ## `date_label_compliance` field Every product response on `GET /v1/products/{gtin}` (and the list/claim/unclaim/PATCH variants) carries a `date_label_compliance` object surfacing a per-product AB 660 readiness summary: ```json theme={null} { "date_label_compliance": { "ca_ab660": "compliant", "federal": "unknown", "notes": [] } } ``` Values: | Field | Meaning | | - | - | | `ca_ab660` | `compliant` (no banned phrases in stored free-text fields), `non_compliant` (one or more hits), or `unknown` (scan could not run). | | `federal` | Reserved. No federal date-label rule is in force today; returns `unknown`. Bipartisan interest exists, so the field is in place for the day one ships. | | `notes` | Empty when compliant; otherwise a list of `"field_name: 'matched phrase'"` strings identifying which fields tripped the check. | The field is computed on read by scanning the four stored free-text fields (`product_name`, `description`, `net_content`, `usage_instructions`) using the same banned-phrase scanner the registration API rejects with, so the GET-time signal and the POST/PATCH-time validation can never disagree. Historical records that pre-date AB 660 enforcement will surface as `non_compliant` here even though they were accepted at write time — use the `notes` to drive an internal cleanup pass. ## Exemptions AB 660 does not apply to every food category. The following are explicitly exempt from the standardized phrasing requirement; brands selling these categories may continue to use legacy phrasing on the regulated surface: * **Eggs** (shell eggs and egg products) * **Infant formula** * **Beer and other malt beverages** * **USDA-regulated poultry products** Closient still applies the banned-phrase scan to **all** product registrations regardless of category, because the catalog spans multiple jurisdictions and the consumer-facing resolver page is rendered everywhere. If you sell an exempt category and need to override the scan for a specific field, contact [support@closient.com](mailto:support@closient.com). ## Related * [Errors](/errors) — standard error envelope, including the 422 returned by the banned-phrase check. * [GS1 Digital Link Resolution](/guides/digital-link-resolution) — how AI (15/17/7003) date data flows through the resolver. * [US State Ingredient Disclosure](/compliance/us-state-ingredient-disclosure) — sibling state-law compliance pattern (LA SB 14, TX SB 25); different mechanism, same compliance-as-config approach. * [AB 660 bill text](https://leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id=202320240AB660) (California Legislature) * [CDFA: Food Date Labeling](https://www.cdfa.ca.gov/is/foodrecovery/fooddatelabeling/) (regulator implementation page) # Hand work to your AI agent Source: https://docs.closient.com/guides/agent-handoff What the "Copy for your AI agent" button in your Closient dashboard does, what a capsule contains, and how it keeps your credentials out of the agent's hands. Some pages in your Closient dashboard have a **Copy for your AI agent** button. It copies a *capsule*: a plain-text work order you paste into Claude, Cursor, or any other AI agent so the agent can do a piece of catalog work for you, such as filling in a product's nutrition panel or adding products from your website. The agent does the work through Closient's API or [MCP server](/guides/mcp-install). You stay in control: you decide what to paste, and you approve the agent's access in your browser. ## What the button does 1. You click **Copy for your AI agent** on a supported page. 2. Closient writes a capsule that describes the current state of that page's product, brand, or organization, and copies it to your clipboard. 3. You paste it into your agent. Click **See what your agent will receive** under the button to read the exact text before you copy it. The capsule is generated on the server from your real data, so it always matches what is in your account when you click. The button only appears on pages where an agent can complete the work end to end. ## Where the button appears | Page | What the agent is asked to do | | - | - | | Add a product | Create products from your brand's website and files. | | Edit product: basic info, dimensions, classification, images, ingredients, nutrition, documents, videos | Fill in or correct that part of one product. | | Product resolver rules | Manage the rules that decide where a scan of the product goes. | | Custom URLs | Manage the custom destination links for a product. | | Brand profile and brand settings | Fill in the empty fields of your brand profile from your website. | ## Anatomy of a capsule Every capsule has the same sections, in the same order: * **Capsule ID.** A unique identifier for this capsule, starting `cap_`. * **Current state.** What Closient already has, so the agent does not redo finished work. For example, the brands and product count in your organization. * **Your task.** The steps to follow, and what to ask you before starting. Agents are told to use only values from your own website and files, never to guess a barcode, and to confirm with you before saving anything they are unsure about. * **How to connect.** The MCP server command (preferred) and the REST API fallback. * **Reference.** A link to the matching [Agent Skill](/agent-skills/overview) with full documentation for the workflow. * **Acceptance criteria.** Checks the agent can run to confirm it finished. ## Security * **A capsule never contains a credential.** No password, token, or API key is in the text. Pasting a capsule into an agent does not give it access to your account. * **The agent connects with OAuth, and you approve it.** When the agent adds the Closient MCP server, your browser opens Closient's consent screen. The agent gets access only after you approve it there. See [MCP Authentication](/guides/mcp-authentication). * **The REST fallback is also yours to grant.** If the agent uses the REST API instead, the capsule asks you to create an API key yourself. Closient never sends one to the agent. * **Agents act on your behalf.** Closient records both the agent and the user it acts for. See [MCP Identity Propagation](/guides/mcp-identity). Read the capsule before you paste it, and read your agent's plan before you let it save. Capsules tell agents to check with you, but you are the final reviewer. ## For agent authors If your agent receives a capsule: * Send the capsule's ID on every Closient API call you make while doing that work, in the `X-Closient-Capsule-Id` header. Closient uses it to connect your requests to the capsule they came from. A value that is not in the `cap_` format is ignored and never causes a request to fail. * The capsule links to the skill for its workflow. All skills are listed at [`/.well-known/agent-skills/index.json`](https://www.closient.com/.well-known/agent-skills/index.json), and are documented in [Agent Skills](/agent-skills/overview). * Prefer the MCP path when the capsule offers it. See [Install the Closient MCP Server](/guides/mcp-install). ## Related * [Install the Closient MCP Server](/guides/mcp-install) * [Agent Skills](/agent-skills/overview) * [How AI Agents Use Closient](/prompts/mcp-for-ai-agents) # Data Delivery Source: https://docs.closient.com/guides/data-delivery Nightly delivery of your scan and page-visit aggregates to your own S3, R2 or Postgres. Closient Data Delivery writes your organization's scan and page-visit analytics into a destination you own, every night. It is provisioned per organization and requested from the [Data Delivery page](https://www.closient.com/data-delivery/) on the Closient site. This page is the v1 data contract: what is delivered, in what shape, and what you can rely on. ## What this is You choose the destination and supply the connection details. We deliver Parquet or CSV files into object storage, or rows into a Postgres schema. Nothing about the data or its shape changes with the destination. The data delivered is the same daily aggregates that power your Closient analytics dashboard. It is retained by Closient indefinitely, so the first delivery includes your full history back to your first scan. ## Delivery | Item | Contract | | - | - | | Cadence | Once per day, starting at 04:00 UTC, covering the previous UTC calendar day. | | Unit of delivery | One partition per table per UTC date. A partition is always written whole. | | Re-delivery | A date partition may be rewritten for up to 3 days after first delivery as late enrichment lands. Treat `date` as the replace key: replace the partition, never append to it. | | Backfill | On activation we deliver every date from your first scan to yesterday. You can request a backfill of any date range at any time from the dashboard. | | Time zone | All dates and timestamps are UTC. `hour` breakdowns are UTC hours. | | Egress | Deliveries originate from a fixed IP address shown in your dashboard, so you can allowlist it. The address is not published anywhere public. | | Ordering | Tables within a day are delivered in the order listed under [Tables](#tables). The manifest is written last, so its presence means the day is complete. | ## Formats and layout ### Object storage (S3, R2, GCS) Default format is Parquet with Snappy compression. CSV (UTF-8, RFC 4180, header row, ISO 8601 dates) is available as an option per destination. ``` /closient/v1//dt=YYYY-MM-DD/.parquet /closient/v1/_manifest/dt=YYYY-MM-DD.json ``` Hive-style `dt=` partitioning is used so DuckDB, Spark, Athena, Snowflake external tables and BigQuery external tables read the tree directly. The `products` table is a full snapshot each night and lives under the same `dt=` partition so a day is self-contained. The manifest lists every object written for that date with row count, byte size, SHA-256 and the schema version: ```json theme={null} { "schema_version": "1", "organization_id": "org_2xk9…", "date": "2026-09-06", "generated_at": "2026-09-07T04:03:12Z", "files": [ {"table": "scans_daily", "path": "closient/v1/scans_daily/dt=2026-09-06/org_2xk9.parquet", "rows": 412, "bytes": 30188, "sha256": "…"} ] } ``` ### Postgres We write into one schema, `closient` by default, that you create and grant to the user you give us. Tables are created on first delivery with the primary keys listed under [Tables](#tables). Each day is applied as an upsert on the primary key inside one transaction per table. A `closient._deliveries` table records every run with the same fields as the manifest. Requirements: TLS with a certificate we can verify (`sslmode=verify-full`), network reachability from the egress IP shown in your dashboard, and a user with `CREATE` on the schema and `INSERT`, `UPDATE`, `SELECT` on its tables. We never touch objects outside that schema. ## Tables Column types are given as Parquet / Postgres. ### `products`: dimension, full snapshot nightly Primary key: `product_id` | Column | Type | Notes | | - | - | - | | `product_id` | string / text | Closient short id, stable for the life of the product | | `gtin` | string / text | GTIN-14, zero-padded | | `brand_id` | string / text | Closient short id | | `brand_name` | string / text | As shown in your catalog on the snapshot date | | `product_name` | string / text | As shown in your catalog | | `resolver_enabled` | boolean | Whether the product currently resolves | | `created_at` | timestamp(UTC) / timestamptz | | | `snapshot_date` | date | The `dt=` partition date | ### `scans_daily`: one row per product per day Primary key: `(date, product_id)` A scan is a resolver hit on one of your GS1 Digital Link URLs. Counts are final once the partition stops being rewritten (see [Delivery](#delivery)). | Column | Type | Notes | | - | - | - | | `date` | date | UTC calendar day | | `product_id` | string / text | Joins to `products` | | `gtin` | string / text | Denormalized for convenience | | `total_scans` | int64 / bigint | All scans including bots | | `billable_scans` | int64 / bigint | Scans that count toward plan usage | | `bot_scans` | int64 / bigint | Scans classified as automated | | `ai_bot_scans` | int64 / bigint | Subset of `bot_scans` attributed to an AI provider | | `hosted_page_scans` | int64 / bigint | Scans that rendered a Closient hosted page | | `redirect_only_scans` | int64 / bigint | Scans that redirected to your destination | | `unique_visitors_estimate` | int64 / bigint | Distinct visitors, estimated with a HyperLogLog sketch, about 1.6% standard error. Not summable across days; sum `total_scans` instead | ### `scans_daily_breakdown`: long table, one row per product, day, dimension, key Primary key: `(date, product_id, dimension, key)` Rows exist only for keys with at least one scan. The sum of `scans` over all keys of one dimension equals `total_scans` for that product and day except where noted. | Column | Type | Notes | | - | - | - | | `date` | date | | | `product_id` | string / text | | | `gtin` | string / text | | | `dimension` | string / text | One of the values below | | `key` | string / text | Meaning depends on `dimension` | | `scans` | int64 / bigint | | | `dimension` | `key` values | | - | - | | `country` | ISO 3166-1 alpha-2, or empty when unknown | | `region` | ISO 3166-2 subdivision code, for example `US-TX` | | `city` | City name as geolocated, for example `Austin` | | `device` | `Mobile`, `Tablet`, `Desktop`, or empty when unknown | | `context` | GS1 Digital Link context key, single character, as carried in the scanned URL | | `hour` | `0` to `23`, UTC hour of the scan | | `ai_provider` | AI crawler provider name. Sums to `ai_bot_scans`, not `total_scans` | | `ai_purpose` | AI crawler purpose classification. Sums to `ai_bot_scans` | | `lot` | Raw AI(10) lot value carried in the scanned URL. Only scans carrying a lot appear | | `link_type` | Raw `linkType` requested by the scanning client, for example `gs1:pip`. Only scans requesting a link type appear | ### `page_visits_daily`: one row per attribution tuple per day Primary key: `(date, gtin, page_type, source, country, is_bot)` A page visit is a render of a Closient hosted page in your organization. A scan that lands on a hosted page produces one scan and one page visit with `source = resolve`. Visits from search, AI assistants and links are page visits with no matching scan. | Column | Type | Notes | | - | - | - | | `date` | date | | | `gtin` | string / text | GTIN-14 of the product page, empty for brand and custom pages | | `page_type` | string / text | `product_info`, `brand_page`, `recipe`, `instructions`, `recall_notice`, `custom` | | `source` | string / text | `resolve`, `ai_referral`, `organic_search`, `direct`, `referral`, `social`, `internal`, `unknown` | | `country` | string / text | ISO 3166-1 alpha-2, or empty when unknown | | `is_bot` | boolean | Whether the visits were classified as automated | | `visits` | int64 / bigint | | ### Available on request Hourly grain (`scans_hourly`, `page_visits_hourly`) with the same columns keyed on `bucket_start` instead of `date`. Hourly data is retained by Closient for a shorter window than daily, so backfill depth is limited. ## What is not delivered Raw scan events, IP addresses, user-agent strings, visitor identifiers, HyperLogLog sketches, and anything that describes an individual visitor. The aggregates above are the privacy-safe layer that Closient itself retains after raw events are purged. If you need per-scan events in real time, use [Scan Webhooks](/guides/webhooks), which are a separate feature. ## Schema versioning * Adding a column, a `dimension` value, or an enumeration value is not a breaking change and does not change the version. Read by column name, not position, and tolerate unknown enumeration values. * Renaming or removing a column, changing a type, or changing a primary key is a breaking change. It ships under a new prefix (`closient/v2/`, schema `closient_v2`) and both versions are delivered in parallel for at least 90 days. We announce breaking changes by email 30 days ahead. * Every manifest and every `_deliveries` row carries `schema_version`. ## Destinations and credentials | Destination | What you give us | What we hold | Notes | | - | - | - | - | | Amazon S3 | Bucket, prefix, region, and an IAM role ARN that trusts Closient's AWS principal with an External ID we display to you | No secret of yours | Role needs `s3:PutObject` and `s3:AbortMultipartUpload` on the prefix and `s3:ListBucket` on the bucket. Preferred path | | Amazon S3 (static keys) | Access key pair scoped to the bucket | The key pair, encrypted at rest | Accepted, role preferred | | Cloudflare R2 | Bucket, account id, and an R2 API token with Object Read & Write on that bucket | The token, encrypted at rest | | | Postgres | Host, port, database, schema, user, password, and the server CA if private | The password, encrypted at rest | TLS required, egress IP must be allowed | | Google Cloud Storage, Snowflake, BigQuery, SFTP | On request | | Quoted individually | Credentials you give us are encrypted at rest, visible to no one at Closient in plain text, rotatable by you at any time from the dashboard, and deleted within 24 hours of deactivation. Object-storage destinations are write-only from our side. ## Monitoring and failure handling * Every run is recorded in the dashboard: date, tables, rows, bytes, duration, outcome. * A failed run is retried hourly up to 6 times. * An authentication or permission error (expired key, revoked role, rejected password) is emailed to your organization's technical and billing contacts immediately, because it never self-heals. * Any other failure is emailed after 2 consecutive failed nights, so a transient on your side does not page you and staleness stays under 48 hours. Errors are sent as we saw them, redacted of credentials. * A missed day is delivered automatically on the next successful run. Nothing is skipped silently. ## Activation and billing * Provisioned per organization. Activation completes within 10 business days of receiving valid destination details. * Billing starts on the day of your first successful delivery, not before. * Price: \$49 per month per destination. No setup fee. * Available on the Business plan. # GS1 Digital Link Resolution Source: https://docs.closient.com/guides/digital-link-resolution Resolve GTINs to structured product data using configurable resolution rules. The Closient Resolver implements the [GS1 Digital Link](https://www.gs1.org/standards/digital-link) standard, enabling any GTIN to be resolved to structured product data via a standard URI format. Resolution behavior is configurable through rules that support time-based routing, scope hierarchy, and custom destinations. ## URI Format A GS1 Digital Link URI encodes a primary identifier and optional qualifiers as path segments: ``` https://www.closient.com/01/{gtin} https://www.closient.com/01/{gtin}/10/{lot}/21/{serial} ``` ### Supported Application Identifiers | AI | Name | Example | Description | | - | - | - | - | | `01` | GTIN | `00012345678905` | Primary product identifier | | `00` | SSCC | `123456789012345678` | Shipping container code | | `10` | Batch/Lot | `ABC123` | Production batch | | `21` | Serial | `SN001` | Individual item serial | | `17` | Expiry | `261231` | Expiry date (YYMMDD) | | `414` | GLN (Party) | `1234567890123` | Party identifier | | `417` | GLN (Location) | `1234567890123` | Location identifier | ## Content Negotiation The resolver returns different formats based on the `Accept` header: | Accept Header | Response | | - | - | | `application/json` | JSON product data (default) | | `application/linkset+json` | GS1 Linkset JSON | | `text/html` | HTML redirect to product information page | ## Resolution Rules Resolution behavior is controlled through rules configured per organization. Rules determine where a Digital Link request is routed. ### Scope Hierarchy Rules are evaluated from most specific to least specific scope. The first matching rule wins: | Priority | Scope | Matches | | - | - | - | | 1 (highest) | **Serial** | Specific serial number within a product | | 2 | **Batch** | Specific batch/lot within a product | | 3 | **Product** | A specific GTIN | | 4 | **Brand** | All products under a brand | | 5 (lowest) | **Organization** | All products in the organization | Within each scope, rules are ordered by `order_index` (lower = higher priority). ### Destination Types | Type | Behavior | | - | - | | `HOSTED_PAGE` | Redirect to Closient-hosted product information page | | `CUSTOM_URL` | Redirect to your own URL | For custom URLs, configure fallback behavior when the URL is unreachable: * **Skip rule** — evaluate the next rule in priority order * **Fallback to hosted** — use the Closient hosted page instead Custom URL health is monitored automatically with consecutive failure tracking. ### Time-Based Routing Rules support time criteria for conditional routing (all criteria are AND-ed when present): | Criteria | Format | Example | | - | - | - | | Absolute window | UTC datetimes | 2026-01-01 to 2026-12-31 | | Annual window | MM-DD (supports year wrap) | 11-01 to 02-28 (holiday season) | | Day of week | 0=Monday through 6=Sunday | \[0, 1, 2, 3, 4] (weekdays only) | | Time of day | HH:MM | 09:00 to 17:00 | Time criteria use the organization's configured routing timezone (IANA format, e.g., `America/New_York`). ### Link Types Rules can be scoped to specific GS1 link types: | Link Type | Description | | - | - | | `gs1:pip` | Product information page | | `gs1:certificationInfo` | Certifications and compliance | | `gs1:sustainabilityInfo` | Sustainability data | | `gs1:recipeInfo` | Ingredients and recipes | | `gs1:ingredientsInfo` | Ingredient disclosure (e.g., [US state disclosure laws](/compliance/us-state-ingredient-disclosure)) | | `gs1:safetyInfo` | Safety / warning information (paired with `gs1:ingredientsInfo` for LA SB 14) | | `gs1:traceability` | Supply chain traceability | ## GS1 Conformance The Closient Resolver is designed to conform to the GS1 Digital Link standard, including proper handling of `Accept` headers, `Link` response headers, and linkset JSON format. A built-in conformance test suite is available for validation. See the [Resolver API reference](/api-reference/resolver) for full endpoint documentation. # EPCIS 2.0 Conformance Source: https://docs.closient.com/guides/epcis-conformance The roles, binding, and industry profiles Closient's EPCIS 2.0 implementation claims — and states plainly which it does not. EPCIS is a large standard covering warehouse logistics, environmental telemetry, asset pairing, and business-document exchange. Closient implements the part that answers two questions: **where did this lot go, and what else is affected.** The rest we have deliberately not built, and this page says which is which. ## The role we claim EPCIS 2.0 has no compliance levels or certification badge. Conformance is claimed per role, and an implementation is judged only on the roles it names. | Role | Status | Notes | | - | - | - | | EPCIS 2.0 capture interface server | **Claimed** | Documents arrive, are validated, and become a queryable traceability record. | | EPCIS 2.0 data | **Claimed** | Events we emit validate against the standard's schema. | | EPCIS 2.0 query interface server | Not claimed | We serve a convenience read over your own events, not the standard's query interface — no named queries, no cross-organization access, no subscriptions. Traceability answers come from purpose-built endpoints, not a general query surface. | | Query callback interface implementation | Not claimed | Subscriptions are deliberately out of scope — see the federation row below, not accidentally missing. | Binding: **REST over HTTPS**. Serialization: **JSON and JSON-LD**. Vocabulary: **GS1 CBV 2.0**. ## What we support * **Supported** — built, and exercised by our conformance suite. * **Stored, not interpreted** — we keep your data faithfully; we do not act on it. * **By design** — a product decision, not on the roadmap. * **Not built** — a real gap in a role we claim, being closed. ### Capture | Capability | Status | Notes | | - | - | - | | Asynchronous capture with a pollable job | Supported | 202 plus a Location header; per-event errors name which events failed by index. | | All-or-nothing capture | Supported | The default — a traceability ledger should not absorb half a shipment. proceed is available when you want it. | | ObjectEvent, AggregationEvent, TransformationEvent | Supported | TransformationEvent is how one raw lot becomes many finished products, and how a recall is scoped to those rather than the whole line. | | TransactionEvent, AssociationEvent | Supported | Accepted and stored so your record is complete; no workflow is built on them. | | Lot attributes (ILMD) — harvest dates, growing area, expiry | Supported | | | Validation — malformed documents rejected | Supported | 58 of 58 corpus documents (valid and deliberately invalid) handled correctly, 0 wrongly accepted. | | Capability discovery (OPTIONS) and the full GS1-\* header set | Supported | All 6 resources we answer OPTIONS on carry an accurate Allow header; every response, errors included, carries the 8-header GS1 set. Count reads from the capability registry rather than being retyped, so it can't drift. | ### Traceability | Capability | Status | Notes | | - | - | - | | Recall blast radius — what else is affected | Supported | Walks transformations forward from a lot to every finished product carrying it. | | FSMA 204 Critical Tracking Events | Supported | Harvest, cooling, initial pack, ship, receive, land receive — derived automatically from captured events. | | FDA export, and historical "what was on file as of" | Supported | | ### Sensor and environmental data | Capability | Status | Notes | | - | - | - | | Sensor readings on events (sensorElementList) | Stored, not interpreted | Captured and returned verbatim. We do not evaluate thresholds, raise excursion alerts, or monitor cold chain. | | Cold-chain excursion alerting, environmental monitoring | By design | A shipment that ran warm is a logistics problem. We are here for the product-quality consequence — a cooling CTE or a recall — not a temperature feed. | ### Interchange | Capability | Status | Notes | | - | - | - | | JSON and JSON-LD | Supported | | | XML, EPCIS 1.2, and older interchange formats | By design | EPCIS 2.0 is the JSON release and the REST binding defines JSON request bodies. We are not building a bridge to the previous generation. | ### Federation | Capability | Status | Notes | | - | - | - | | Named queries, standing queries, subscriptions | By design | Genuinely valuable and genuinely unbuilt — every read today is scoped to the organization that captured the events. | | Cross-party event deduplication (GS1 event Hash-ID) | By design | The standard event Hash-ID is stored for newly captured events whose @context it can resolve; events captured earlier have none until a backfill runs. Duplicate detection still uses a Closient-internal fingerprint scoped to one organization, and the Hash-ID is not yet queryable. | ## Industry profiles | Profile | Position | | - | - | | FSMA 204 | Supported — CTE materialisation and ILMD KDE mapping shipped. | | DSCSA / Rx | No claim. The bar is the GS1 US Rx EPCIS Conformance Testing Program via Drummond Group, which we have not been through. | | GDST (seafood) | No claim. | Nothing on our site, in our docs, or in a sales conversation implies DSCSA/Rx or GDST readiness until those rows change. ## How we know * **12 of 63** official EPCIS 2.0 REST operations served (6 of 29 paths), all within the roles claimed above. * **58 of 58** real EPCIS documents from an independent conformance corpus handled correctly (0 wrongly accepted, 0 server errors). * Measured against the GS1 EPCIS 2.0 REST binding and an independent 138-document conformance corpus, both checked in CI on every build. **Why we publish the gaps.** A claim is only worth something if it survives being checked, and the fastest way to find out whether we are honest is to look at what we admit when nobody is forcing us to. # EPCIS 2.0 Events Source: https://docs.closient.com/guides/epcis-events Capture and query supply chain visibility events per ISO/IEC 19987:2024. Closient implements [EPCIS 2.0](https://www.gs1.org/standards/epcis) (Electronic Product Code Information Services) per ISO/IEC 19987:2024 for capturing and querying supply chain visibility events. Events answer the four dimensions of supply chain visibility: **What**, **When**, **Where**, and **Why**. ## Event Types | Event Type | Use Case | Action Required | | - | - | - | | **ObjectEvent** | Observing, inspecting, or acting on specific objects | ADD, OBSERVE, or DELETE | | **AggregationEvent** | Packing/unpacking objects into containers | ADD, OBSERVE, or DELETE | | **TransactionEvent** | Associating objects with business transactions | ADD, OBSERVE, or DELETE | | **TransformationEvent** | Input objects transformed into output objects | No action field | | **AssociationEvent** | Associating objects with other objects | ADD, OBSERVE, or DELETE | ## Event Structure Every event has four dimensions: ### What (Identifiers) * **epcList** — list of individual EPC/GTIN URIs involved * **quantityList** — class-level quantities (GTIN without serial, plus quantity and UOM) * **parentID** — container identifier for aggregation events * **inputEPCList / outputEPCList** — for transformation events ### When (Time) * **eventTime** — when the event occurred (ISO 8601) * **eventTimeZoneOffset** — timezone offset (e.g., `+05:30`) * **recordTime** — when the system captured the event (set automatically) ### Where (Location) * **readPoint** — GS1 Digital Link URI of the scan location * **bizLocation** — GS1 Digital Link URI of the business location ### Why (Business Context) * **bizStep** — business step from CBV 2.0 vocabulary (40+ values) * **disposition** — object state after the event (20+ values) ## Capturing Events ```bash theme={null} curl -X POST \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ https://www.closient.com/epcis/api/2.0/capture \ -d '{ "type": "ObjectEvent", "eventTime": "2026-04-01T12:00:00Z", "eventTimeZoneOffset": "+00:00", "action": "OBSERVE", "epcList": ["urn:epc:id:sgtin:0012345.078905.1"], "bizStep": "urn:epcglobal:cbv:bizstep:inspecting", "disposition": "urn:epcglobal:cbv:disp:in_progress", "readPoint": {"id": "urn:epc:id:sgln:0012345.00001.0"}, "bizLocation": {"id": "urn:epc:id:sgln:0012345.00001.0"} }' ``` Events are deduplicated by SHA-256 hash. Submitting the same event twice returns the existing event without creating a duplicate. ## Business Step Vocabulary (CBV 2.0) Common business steps: | bizStep | Description | | - | - | | `accepting` | Accepting goods into custody | | `arriving` | Goods arriving at a location | | `commissioning` | Assigning identity to an object | | `decommissioning` | Removing identity from an object | | `departing` | Goods leaving a location | | `inspecting` | Quality inspection | | `packing` | Packing items into containers | | `receiving` | Receiving goods from a supplier | | `shipping` | Shipping goods to a destination | | `storing` | Placing into storage | | `unpacking` | Removing items from containers | ## Disposition Vocabulary Common dispositions: | disposition | Description | | - | - | | `active` | Object is active and in use | | `available` | Available for use or sale | | `damaged` | Object is damaged | | `expired` | Past expiry date | | `in_transit` | Currently being transported | | `recalled` | Subject to recall | | `reserved` | Reserved for a specific purpose | ## Persistent Dispositions EPCIS 2.0 introduces persistent dispositions — dispositions that remain in effect across events: * **persistent\_disposition\_set** — dispositions to add to the object's persistent state * **persistent\_disposition\_unset** — dispositions to remove This enables tracking long-lived states like certifications or regulatory holds that span multiple events. ## Instance/Lot Master Data (ILMD) The `ilmd` field carries master data that is defined at the instance or lot level at the time of creation: ```json theme={null} { "ilmd": { "bestBeforeDate": "2027-01-15", "countryOfOrigin": "US", "lotNumber": "LOT2026Q1" } } ``` ## Querying Events ```bash theme={null} curl -H "X-API-Key: YOUR_API_KEY" \ "https://www.closient.com/epcis/api/2.0/events?GE_eventTime=2026-01-01T00:00:00Z&eventType=ObjectEvent" ``` See the [EPCIS API reference](/api-reference/epcis) for the full query interface including filtering by EPC, business step, disposition, location, and time range. # Lot-Scoped Recalls Source: https://docs.closient.com/guides/lot-scoped-recalls Which GS1 Application Identifiers a lot-managed product needs so a recall can be scoped to specific lots, and how resolver rules turn that identity into scan-time recall status. A recall is only as precise as the identity printed on the product. If the finest-grained identifier a scanner can read is a GTIN, then every unit of that GTIN is indistinguishable at the point of sale, and the only safe scope for a recall is all of them. Encoding the lot alongside the GTIN is what makes a narrower scope provable. This page covers which Application Identifiers (AIs) to encode for lot-managed goods, what each one buys you during a recall, and how Closient resolves recall status per lot. > For a worked case study of what the absence of this layer costs, see [The 2026 Lettuce Recall Was Scoped by a Date Printed in Ink](https://blog.closient.com/lettuce-recall-scoped-by-ink/) on the blog. ## The minimum viable recall identity Most food and beverage products are lot-managed rather than serialized: units are produced and tracked in batches, and individual units carry no unique identity. For these products three AIs are enough to make a recall surgical. | AI | GS1 meaning | Why it matters in a recall | | - | - | - | | `01` | GTIN | Identifies the product. On its own it cannot distinguish one batch from another. | | `10` | Batch/Lot number | The unit of recall. This is the field that lets a recall cover some units and not others. | | `15` | Best Before Date | Frequently the field a public recall notice actually uses to describe affected stock, because it is already printed for consumers. | A Digital Link URI carrying all three looks like this: ``` https://id.closient.com/01/00012345678905/10/L2026-1842/15/260803 ``` `10` is the load-bearing one. A Digital Link with only `01` is a more capable UPC: it reaches a product page, and it can tell you nothing about which unit you are holding. ### Why AI 15 as well as AI 10 Including the best before date is not redundant. Recall notices are routinely written against a date range rather than a lot list, because the date is the thing a consumer can read off the pack and a lot code often is not printed on consumer packaging at all. Encoding `15` means a scan can answer the recall as it was actually worded, without the consumer having to find and interpret a case-level lot code. `15` renders to consumers as **Best if Used By**. See [AB 660 / Date Labels](/guides/ab-660-date-labels) for the full AI-to-phrase mapping and the banned-phrase validation applied on product registration. ### When to add AI 21 Add the serial number (`21`) only when units genuinely carry unique identity - pharmaceuticals under DSCSA, high-value goods, warranty-tracked items. For lot-managed produce and most packaged food, serialization is neither economical nor necessary: the recall scope is the lot, so the lot is the right granularity. Encoding a serial you cannot actually track per unit adds characters to the barcode and buys nothing. Every added AI lengthens the encoded string and therefore the printed symbol. See [QR Code Sizing](/user-guides/qr-code-sizing) for what each field costs in module count and minimum X-dimension before committing to a payload. ## How recall status resolves per lot Closient evaluates resolver rules against the AIs present in the incoming request, so a rule can be scoped to a lot without touching the rest of the catalog. 1. **A recall is recorded against specific lots** of a GTIN, either entered directly or matched from an ingested regulatory notice. 2. **Scans carrying `10`** are matched against those lots. A scan of an affected lot resolves to the recall destination; a scan of any other lot of the same GTIN resolves normally. 3. **Scans carrying only `01`** cannot be attributed to a lot. These fall back to the product-level treatment configured for the recall, since the system cannot prove the unit is unaffected. 4. **When the recall is lifted**, the rule is withdrawn and affected lots resolve normally again, so a re-scan shows the all-clear without a new label or a new code. Point 3 is the practical argument for encoding `10` at print time rather than planning to add it later. Recall behavior degrades to product-wide for any unit that does not carry a lot, which is the same blunt outcome as having no Digital Link at all. See [Resolver Rules](/user-guides/resolver-rules) for rule precedence and configuration, and [Check Recalls](/agent-skills/check-recalls) for querying recall status programmatically. ## Supply-chain events Scan-time status answers "is this unit recalled." Scoping the recall in the first place means knowing which lots went where, which is what EPCIS events provide: capture at pack, ship and receive, and the question "which locations received these lots in this window" becomes a query rather than a records request. This is also the shape of what the FDA's FSMA 204 rule requires for foods on the Food Traceability List - traceability lot codes, key data elements at critical tracking events, and records producible within 24 hours. The compliance date is July 20, 2028, extended from January 20, 2026. See [EPCIS Events](/guides/epcis-events) for capturing and querying them. ## What to do before your next print run * Confirm whether each product is lot-managed or serialized, and encode `10` or `21` accordingly - not both by default. * Add `15` for anything with a consumer-facing best before date, so a date-scoped recall notice is machine-answerable. * Check the resulting payload length against your label's available area before committing to the artwork. * Register the lots you produce, so that a recall entered against a lot has something to match. None of this changes what you print in human-readable text. It changes whether a machine can act on it. # MCP Authentication Source: https://docs.closient.com/guides/mcp-authentication How OAuth 2.1 step-up authorization works for the Closient MCP server's protected tools. The Closient MCP server is an OAuth 2.1 resource server, classified per [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) (Protected Resource Metadata) and the [MCP Authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization). Most tools are public — no token, no setup, frictionless adoption for AI agents discovering Closient through `tools/list`. A smaller set of tools is gated behind a scoped OAuth token: `generate_qr_url` (touches the paid QR-generation pipeline) and the brand-owner product write tools added in C-4038 (create/edit products, images, documents, videos, nutrition, ingredients — see [Tool Reference](/guides/mcp-tools)). The pattern is extensible: future protected tools join the same step-up flow. ## Two roles, two endpoints Closient acts as both authorization server and resource server in this stack: | Role | What it does | Discovery endpoint | | - | - | - | | Authorization Server | Issues and validates OAuth tokens, hosts the consent screen, manages refresh. | [`/.well-known/oauth-authorization-server`](https://www.closient.com/.well-known/oauth-authorization-server) | | Resource Server (MCP) | Validates the bearer token, enforces scopes, dispatches the tool call. | [`/.well-known/oauth-protected-resource`](https://www.closient.com/.well-known/oauth-protected-resource) | The two discovery endpoints conform to RFC 8414 (authorization server metadata) and RFC 9728 (protected resource metadata) respectively. Compliant MCP clients fetch them automatically — you do not need to configure URLs by hand. ## Public tools — no token These tools accept anonymous calls. No header, no setup: * `ping` * `validate_gtin` * `validate_digital_link` * `resolve_gtin` * `lookup_product` * `search_products` * `get_product_detail` * `compare_products` * `check_availability` If you pass a valid token alongside one of these calls, Closient records the authenticated principal in the audit log (see [MCP Identity Propagation](/guides/mcp-identity)) but the call behaves identically. This is forward-compat plumbing for future features like "authenticated tier: higher rate limit." ## Protected tools — step-up flow `generate_qr_url` requires a bearer token with the `qr:generate` scope; the product write tools require `products:write` or `dashboard:write` depending on which tool (see [Scopes](#scopes) below). The step-up flow is what makes the user experience smooth: the client doesn't have to authenticate up front, only when it actually tries to use a protected tool. The product write tools carry one additional requirement beyond scope: they need an **end-user** token — one minted via the `authorization_code` grant, where a real person approved the consent screen. A `client_credentials` token (an agent acting on its own behalf with no consenting end user) is rejected even with the right scope, because every write is attributed to a specific brand- owner user in the audit trail. `generate_qr_url` has no such restriction. ```mermaid theme={null} sequenceDiagram participant Agent as MCP Client (Claude/Cursor/VS Code) participant Closient as Closient MCP Server participant User Agent->>Closient: tools/call generate_qr_url Closient-->>Agent: 403 + WWW-Authenticate: Bearer scope="qr:generate" Note over Agent: MCP client reads header, starts OAuth flow Agent->>User: Browser opens consent screen User->>Closient: Approves "Allow to generate QR URLs" Closient-->>Agent: authorization_code → exchanged for access_token Agent->>Closient: tools/call generate_qr_url + Authorization: Bearer Closient-->>Agent: 200 + { qr_url, gtin14, host } ``` Step by step: 1. **First call without a token.** The MCP client calls the tool anonymously. Closient returns `403 Forbidden` with this header: ``` WWW-Authenticate: Bearer error="insufficient_scope" scope="qr:generate" resource_metadata="https://www.closient.com/.well-known/oauth-protected-resource" ``` 2. **Client reads `resource_metadata`** and fetches the protected resource metadata document to learn the authorization server URL and supported scopes. 3. **Authorization flow.** The client opens the consent screen in the user's browser (using PKCE per the OAuth 2.1 spec), the user approves, and the client exchanges the authorization code for an access token + refresh token. 4. **Retry with the token.** The client retries `tools/call generate_qr_url` with `Authorization: Bearer `. Closient validates the token, confirms the `qr:generate` scope, and dispatches the call. The whole flow happens transparently in Tier 1 MCP clients (Claude Desktop, Cursor, VS Code) — the user sees a single consent prompt and then the tool just works. ## Scopes MCP-protected tools use four scopes today. `products:read`, `products:write` and `dashboard:write` are the same per-app scopes REST-API callers use (`APP_API_REGISTRY`) — no MCP-specific scope machinery was needed to add the C-4038 write tools, only new tool registrations against existing scopes. Future protected tools will register additional scopes following the same `domain:action` convention. | Scope | Required for | Description | | - | - | - | | `qr:generate` | `generate_qr_url` | Generate GS1 Digital Link URLs for QR/Data Matrix encoding. Counts against the user's QR quota. | | `products:read` | `get_claim_verification` | Read the verification checklist, filed evidence, and domain-verification token for a product your organization has claimed. | | `products:write` | `create_product`, `update_product`, `submit_claim_evidence`, `check_claim_dns`, `check_claim_http` | Create and edit product identity, dimensions/weight, and GPC classification; file claim evidence for staff review and run the DNS or HTTP domain check. | | `dashboard:write` | `upload_product_image`, `delete_product_image`, `upload_product_document`, `update_product_document`, `delete_product_document`, `add_product_video`, `update_product_video`, `delete_product_video`, `set_product_nutrition`, `set_product_ingredients` | Manage product media, nutrition facts, and ingredients. | Other REST-API scopes (`account:read`, `brands:read`, etc.) are independent of MCP in the sense that holding one does not by itself authenticate an MCP call — but `products:read`, `products:write` and `dashboard:write` are literally the same scope strings the REST API's OAuth-authenticated endpoints check, so a token minted for one surface works on the other's write tools/endpoints for the same permission. ## Token lifetimes | Token | Lifetime | | - | - | | Access token | 24 hours | | Refresh token | 90 days | When an access token expires, the MCP client uses the refresh token to mint a new one without prompting the user. Expired refresh tokens require the user to consent again. ## Client registration The MCP spec (Nov 2025 revision) supports three registration paths. All work against Closient: 1. **Client ID Metadata Documents (CIMD)** — preferred. The client advertises its metadata at a public URL; Closient fetches it on first contact. No upfront registration required. 2. **Dynamic Client Registration (RFC 7591)** — the client `POST`s to the authorization server's `/register` endpoint and receives a `client_id` + `client_secret`. Useful for clients that want a stable identity but don't have a public metadata URL. 3. **Pre-registration** — for known clients like Claude Desktop and VS Code, Closient maintains pre-registered application records. No action on your part; the client uses its built-in `client_id`. Tier 1 MCP clients pick the right registration path automatically. ## Manual token minting For local development, scripted integrations, or MCP clients that don't implement the OAuth flow, you can mint a token by hand: ```bash theme={null} # 1. Register a client (one-time) curl -X POST https://www.closient.com/o/register/ \ -H 'Content-Type: application/json' \ -d '{ "client_name": "my-mcp-test-client", "redirect_uris": ["http://localhost:8765/callback"], "grant_types": ["authorization_code", "refresh_token"], "scope": "qr:generate" }' # 2. Send the user through the consent flow open "https://www.closient.com/o/authorize/?response_type=code&client_id=\ &redirect_uri=http://localhost:8765/callback&scope=qr:generate&code_challenge=&code_challenge_method=S256" # 3. Exchange the resulting code for an access token curl -X POST https://www.closient.com/o/token/ \ -d 'grant_type=authorization_code' \ -d 'code=' \ -d 'redirect_uri=http://localhost:8765/callback' \ -d 'client_id=' \ -d 'code_verifier=' ``` Then call the protected tool with `Authorization: Bearer `. ## Verifying scope coverage A quick way to sanity-check that a token has the right scope before calling a protected tool: ```bash theme={null} curl -sS https://www.closient.com/mcp/http \ -H "Authorization: Bearer $TOKEN" \ -H 'Accept: application/json, text/event-stream' \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate_qr_url","arguments":{"gtin":"00614141123452"}}}' ``` A successful response confirms the token holds `qr:generate`. A `403` with `error="insufficient_scope"` means the token is valid but missing the scope — re-run the consent flow and accept the `qr:generate` request. ## Audit & identity Every tool call (public or protected) emits one structured `closient.mcp.audit` log line with the agent's `client_id` and (for `authorization_code` tokens) the consenting end user. See [MCP Identity Propagation](/guides/mcp-identity) for the full audit schema and the `get_current_end_user()` accessor for tool authors. For protected tools, `@user_action` on the underlying service-layer function (e.g. `apps.products.services.qr_url.build_digital_link_url`) also records a durable database audit row — the answer to "who generated this QR code?" is queryable forever from the `audit_events` table. ## See also * [Install the MCP Server](/guides/mcp-install) * [Tool Reference](/guides/mcp-tools) * [MCP Error Contract](/guides/mcp-errors) * [MCP Identity Propagation](/guides/mcp-identity) * [Authentication (REST API)](/authentication) — API-key auth for the REST API, independent of MCP OAuth. * [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728) * [MCP Authorization spec (draft)](https://modelcontextprotocol.io/specification/draft/basic/authorization) # MCP Error Contract Source: https://docs.closient.com/guides/mcp-errors How Closient's MCP tools surface errors — JSON-RPC envelopes carrying RFC 9457 Problem Details. The Closient MCP server exposes the same business operations as the REST API (GTIN validation, resolution, product lookup, QR-URL generation, more to come) but speaks JSON-RPC 2.0 instead of HTTP. This page documents how MCP tools surface failures so agents can react predictably — distinguishing "GTIN not found" from "rate limited" from "internal error" without parsing free-form strings. ## Envelope shape When a tool fails, the result it returns to the client has an `error` field matching the JSON-RPC 2.0 error-object shape: ```json theme={null} { "error": { "code": -32200, "message": "Not Found", "data": { "type": "https://closient.com/docs/errors/not_found", "title": "Not Found", "status": 404, "detail": "No product found for GTIN 00614141123452", "error_code": "not_found", "retryable": false, "timestamp": "2026-04-26T02:34:56+00:00" } } } ``` The `data` payload is an [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) document — the same structure the REST API returns under `Content-Type: application/problem+json`. Agents that already understand Closient's REST error shape do not need a second mapping. Successful tool calls do **not** include an `error` key — the tool's normal payload is returned directly. ## Code table JSON-RPC error codes are stable and grouped by failure class: | Code | Class | Meaning | Retryable? | | - | - | - | - | | `-32001` | Transport / authentication | Missing or invalid token (`unauthorized`). | After auth | | `-32002` | Transport / authentication | Token lacks required scope (`forbidden`). | No | | `-32003` | Transport / authentication | Rate limit exceeded (`rate_limited`). See `retry_after`. | Yes | | `-32100` | Request validation | Malformed request (`bad_request`). | No | | `-32101` | Request validation | Field-level validation error (`validation_error`). | No | | `-32102` | Request validation | Semantically invalid (`unprocessable_entity`). | No | | `-32103` | Request validation | Payload over the tool's size limit (`payload_too_large`). | No | | `-32200` | Business rule | Resource does not exist (`not_found`). | No | | `-32201` | Business rule | State conflict, e.g. already claimed (`conflict`). | No | | `-32300` | Upstream / internal | Unexpected server error (`internal_error`). | Backoff | Adding new codes within these ranges is non-breaking. Renumbering existing codes is a breaking change. ## Common errors per tool | Tool | Likely codes | | - | - | | `ping` | none — health check has no failure modes. | | `validate_gtin` | none — invalid GTINs return `valid: false` in payload. | | `validate_digital_link` | `-32101` (empty or over-long `uri`). Non-conformant URIs return `is_conformant: false` in payload. | | `resolve_gtin` | `-32101` (invalid GTIN), `-32200` (no rule). | | `lookup_product` | `-32101` (invalid GTIN). Missing GTINs return `found: false` rather than an error. | | `generate_qr_url` | `-32101` (invalid GTIN), `-32002` (missing `qr:generate` scope, when auth wires up). | ## Handling errors in an agent ```python theme={null} result = client.call_tool("resolve_gtin", {"gtin": "00614141123452"}) if "error" in result: err = result["error"] code = err["code"] problem = err["data"] if problem.get("retryable"): # Optional: respect retry_after on rate-limit responses. time.sleep(problem.get("retry_after", 5)) ... elif code == -32200: # Resource not found — surface to the user, do not retry. ... else: # 4xx-class user error or 5xx-class server error. ... else: # Success path — use the tool's normal payload. ... ``` ## Reference * Tracking ticket: [C-2491](https://linear.app/closient/issue/C-2491). * Implementation: `backend/closient/mcp_errors.py`. * REST counterpart: see [Errors](/errors). # MCP Identity Propagation Source: https://docs.closient.com/guides/mcp-identity How Closient's MCP server distinguishes the agent making a call from the end user on whose behalf it acts. The Model Context Protocol authenticates the **agent** (the client app that holds an OAuth token), but most real workflows have a second identity in play: the **end user** the agent is acting on behalf of. Closient's MCP server tracks both — the dual-principal model — so audit logs, rate-limit decisions, and end-user revocation can answer "which user asked the agent to do this," not just "which agent did it." This page explains the two identities, how each is conveyed to Closient, and what guarantees you can rely on as an integrator. ## The two principals | Principal | Identifier | Source | | - | - | - | | Agent | OAuth `client_id` | The OAuth Application that issued the bearer token | | End-user actor | Closient `User.id` | The user who completed the `authorization_code` grant for this token | The agent principal is always present on an authenticated MCP request — if there's a valid bearer token, there's an OAuth Application behind it. The end-user actor is **only** present when the token was issued via the `authorization_code` flow, where the user is part of the consent contract. Tokens issued via `client_credentials` (server- to-server agents acting on their own behalf) carry no end-user actor; the audit log records `end_user_id = null` for those, which is the correct truth. ## How it gets to Closient The MCP spec does not yet standardize a wire format for the end-user actor — there's an active conversation in the MCP community about whether and how to formalize it as a protocol primitive. Until that lands, Closient derives the actor from a primitive that's already unambiguous: the `User` foreign key on the `AccessToken` row that the OAuth server minted for the consent grant. Concretely: 1. The agent obtains a bearer token via OAuth 2.0 `authorization_code` (typical for [Anthropic Connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) or any user-consent flow). The token row in Closient's database has a non-null `user_id` pointing at the consenting user. 2. The agent calls a Closient MCP tool with `Authorization: Bearer `. 3. The MCP dispatch middleware validates the token, resolves the linked `User`, and exposes that user as the end-user actor for the duration of the tool call. `client_credentials` tokens (no user consent step) have a null `user_id`. The middleware sees that and sets `end_user_id = null` in the audit log — the agent is the only principal on those calls. ## The audit log line Every `tools/call` dispatch emits one structured log line on the `closient.mcp.audit` logger. The line is JSON-shaped and ends up in the same Grafana LGTM Loki index as the rest of the application logs. Field-by-field: | Field | Type | Meaning | | - | - | - | | `event` | string | Always `mcp_tool_call` — pin LogQL queries to this. | | `tool` | string | JSON-RPC `params.name` (e.g. `generate_qr_url`). | | `client_id` | string \| null | Agent OAuth `client_id`, or null for unauthenticated public-tool calls. | | `end_user_id` | string \| null | End-user actor's User ID, or null for `client_credentials` / anonymous. | | `status` | string | `allowed`, `denied_missing_token`, `denied_insufficient_scope`, `denied_oversize`, or `error`. | | `required_scopes` | array\ | Scopes the tool requires. `[]` for public tools. | | `input_hash` | string | First 16 hex chars of SHA-256 over canonical JSON of `params.arguments`. | | `request_id` | any | JSON-RPC envelope `id`, echoed for trace correlation. | The `input_hash` is deliberately a hash and not the raw arguments. It is stable — same arguments produce the same hash — so operators can correlate a log line with later state changes without storing GTINs, batch / lot codes, or any other identifier in the audit log. If a deeper investigation needs the raw input, it's recoverable from the request-side trace (which is on a shorter retention window). ## What the audit log is *not* The audit log line is best-effort observability. It's not the durable audit trail for stateful operations — those are recorded in the database via `@user_action` and `@org_action` decorators on the service-layer functions the tool delegates to. Both records exist; they serve different purposes: * **Audit log line** (this page) — every tool call, success or failure. Fast to query, retains for the Loki retention window. Used for SOC2 evidence: "show me every MCP call to `generate_qr_url` in the past 30 days, broken down by `end_user_id`." * **Database audit row** — only for service-layer mutations. Slower to query but durable forever. Used for "who created this resolver rule?" — answerable from the `audit_events` table directly. If you need both — "who called the tool, what did it change, and when" — join the audit-log `end_user_id` with the database audit row on `user_id` and the timestamps will line up. ## Reading the actor from your tool body If you're writing a Closient MCP tool that needs to act on behalf of the end user (record an audit row, scope a query by user, etc.), use the `get_current_end_user()` accessor: ```python theme={null} from closient.mcp_auth import get_current_end_user @mcp.tool() @require_mcp_scope("qr:generate") def my_tool(...) -> dict: end_user = get_current_end_user() # User instance or None return service_layer_call(end_user, ...) ``` Behavior: * `authorization_code` token → returns the `User` row that consented. * `client_credentials` token → returns `None`. * No token → returns `None`. The same actor is also available on the ASGI request scope as `scope["end_user"]`, which is what middleware-level consumers should read. ## Forward compatibility The MCP working group is discussing standardized identity propagation (actor claims in the access token, structured headers, OAuth [Token Exchange](https://www.rfc-editor.org/rfc/rfc8693), etc.). When something lands, Closient will adopt it. The shape of the audit log line and the `get_current_end_user()` accessor will stay stable — those are the public contracts. The internal plumbing (DB FK lookup today, parsed token claim later) is implementation detail. If you depend on the audit-log shape, the safe fields are `event`, `tool`, `client_id`, `end_user_id`, `status`, and `required_scopes`. We may add fields without notice; we will not remove or rename those six without a deprecation cycle. # Install the Closient MCP Server Source: https://docs.closient.com/guides/mcp-install Connect Claude, Cursor, VS Code, and other MCP-compatible AI tools to Closient in one click or with a single command. The Closient MCP server lets any AI agent that speaks the [Model Context Protocol](https://modelcontextprotocol.io) call Closient directly — validate GTINs, resolve them to product pages, look up catalog data, search by natural language, compare products, find nearby availability, and (with auth) generate Digital Link URLs for QR codes. The server is hosted by Closient — you do not install or run anything locally. Pointing your MCP client at the server URL is enough. | | Value | | - | - | | Server URL (Streamable HTTP) | `https://www.closient.com/mcp/http` | | Server URL (legacy SSE) | `https://www.closient.com/mcp/sse` | | Health probe | `https://www.closient.com/mcp/health` | | Authorization Server metadata | [`/.well-known/oauth-authorization-server`](https://www.closient.com/.well-known/oauth-authorization-server) | | Protected Resource metadata | [`/.well-known/oauth-protected-resource`](https://www.closient.com/.well-known/oauth-protected-resource) | Use `https://www.closient.com/mcp/http` for new integrations. The `/mcp/sse` endpoint is kept for backwards compatibility with older MCP clients that haven't adopted the Streamable HTTP transport yet. ## Tier 1 — first-class clients These clients ship MCP support out of the box. Pick the matching tab and follow the snippet — no extra packages, no local server, no JSON hand-editing. Run once from a terminal: ```bash theme={null} claude mcp add closient --transport http https://www.closient.com/mcp/http ``` That's it. `claude mcp list` should now show `closient` and the tools become available to any Claude Code session in this workspace. To remove later: `claude mcp remove closient`. 1. Open **Claude Desktop > Settings > Connectors** (formerly "Custom Connectors"). 2. Click **Add custom connector**. 3. Paste the server URL: ``` https://www.closient.com/mcp/http ``` 4. Save. Claude Desktop will discover the tools and prompt for OAuth the first time you call `generate_qr_url` (see [Authentication](/guides/mcp-authentication)). For a deeper walkthrough see Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). 1. Open [claude.ai](https://claude.ai) and go to **Settings > Connectors > Custom Connectors**. 2. Click **Add custom connector**. 3. Paste: ``` https://www.closient.com/mcp/http ``` 4. Save. The connector is available to all chats on this account. **One-click install:** Click to open Cursor with the Closient server pre-filled. You'll see a confirmation prompt before it's added. **Manual install:** open `~/.cursor/mcp.json` (or use **Settings > MCP > Add new server**) and add: ```json theme={null} { "mcpServers": { "closient": { "url": "https://www.closient.com/mcp/http" } } } ``` Run from a terminal: ```bash theme={null} code --add-mcp '{"type":"http","name":"closient","url":"https://www.closient.com/mcp/http"}' ``` VS Code 1.95+ ships built-in MCP support. The Closient server is added to your user `settings.json` under `"mcp.servers"` and available to GitHub Copilot Chat and any MCP-aware extension. To add manually, open **Settings > MCP > Edit settings.json** and add: ```json theme={null} { "mcp": { "servers": { "closient": { "type": "http", "url": "https://www.closient.com/mcp/http" } } } } ``` ## Tier 2 — Windsurf Windsurf reads a `mcp_config.json` file in the project root (or the global `~/.codeium/windsurf/mcp_config.json`). Add: ```json theme={null} { "mcpServers": { "closient": { "url": "https://www.closient.com/mcp/http" } } } ``` Restart Windsurf and the Closient tools appear in the Cascade panel. ## Tier 3 — generic JSON config Any MCP-compatible client (Cline, Zed, Trae, Continue, custom agents built on the MCP Python or TypeScript SDKs, etc.) can connect with the generic Streamable HTTP entry below. Paste it into whatever configuration format the client uses for MCP servers. ```json theme={null} { "name": "closient", "transport": "http", "url": "https://www.closient.com/mcp/http" } ``` If the client only supports the older HTTP+SSE transport, use: ```json theme={null} { "name": "closient", "transport": "sse", "url": "https://www.closient.com/mcp/sse" } ``` ## What you get Once installed, the client discovers the following tools (no auth required for discovery): | Tool | Auth | Use case | | - | - | - | | `ping` | none | Health probe — returns `pong`. | | `validate_gtin` | none | Check a GTIN's structure and check digit. | | `resolve_gtin` | none | Apply the resolver-rule hierarchy and return the destination URL. | | `lookup_product` | none | Look up structured product data by GTIN. | | `search_products` | none | Natural-language search across the catalog, optionally with geo boosting. | | `get_product_detail` | none | Rich product record — attributes, images, ingredients, nutrition. | | `compare_products` | none | Side-by-side comparison of 2-5 products. | | `check_availability` | none | Find nearby physical stores carrying a GTIN. | | `generate_qr_url` | OAuth | Build the canonical Digital Link URL to encode in a 2D barcode. | Full input/output schemas and examples are in the [Tool Reference](/guides/mcp-tools). ## Authentication The first eight tools above work immediately — no token, no setup. `generate_qr_url` requires an OAuth 2.1 bearer token with the `qr:generate` scope. When you (or your agent) call it without a token, Closient returns `403 Forbidden` with a `WWW-Authenticate` header that tells the MCP client where to start the OAuth flow. Most first-class MCP hosts (Claude Desktop, Cursor, VS Code) handle the prompt-and-retry transparently — see [Authentication](/guides/mcp-authentication) for the full flow. ## Verifying the connection After installing, ask your AI tool something like: > Use the Closient MCP server to validate GTIN 00614141123452. The agent should call `validate_gtin` and report the GTIN is valid (it's a real GS1 test code). If you get a "no MCP server named closient" error, restart the client — most hosts only pick up new server configs on launch. A direct sanity check from a terminal: ```bash theme={null} curl -sS https://www.closient.com/mcp/health ``` A `200 OK` response means the hosted server is reachable from your network. ## Troubleshooting | Symptom | Likely cause | | - | - | | Client can't connect | Corporate proxy or firewall blocking `closient.com`. Try `curl https://www.closient.com/mcp/health` from the same host. | | `tools/list` returns empty | Client is configured for `transport: stdio` instead of `http`. Closient is a remote server only. | | `generate_qr_url` always returns 403 | Token missing the `qr:generate` scope. Re-run the OAuth flow and accept the consent screen — older tokens issued without the scope must be refreshed. | | OAuth pop-up never appears | Client doesn't honour the `WWW-Authenticate` header (some self-built MCP clients). Mint a token manually via [OAuth Authentication](/guides/mcp-authentication) and pass it in `Authorization: Bearer `. | | 401 instead of 403 | Token is expired. Access tokens last 24 h, refresh tokens 90 d. | ## Reference * [Hand work to your AI agent](/guides/agent-handoff) — your Closient dashboard can hand your agent pre-filled tasks. * [Tool Reference](/guides/mcp-tools) — input/output schemas for every tool. * [Authentication](/guides/mcp-authentication) — OAuth 2.1 step-up flow. * [MCP Error Contract](/guides/mcp-errors) — how failures surface over JSON-RPC. * [MCP Identity Propagation](/guides/mcp-identity) — agent vs. end-user actor in audit logs. * [MCP Tool Annotations](/guides/mcp-tool-annotations) — `readOnlyHint` / `destructiveHint` semantics. * [Model Context Protocol spec](https://modelcontextprotocol.io) — the underlying open standard. # MCP Quickstart Source: https://docs.closient.com/guides/mcp-quickstart Connect Claude to Closient and resolve your first GTIN in under two minutes. This walkthrough goes from "no MCP server installed" to "Claude just resolved a GTIN" in about two minutes. We use Claude Code for the example because it's the fastest path on every platform, but the same flow works in any [Tier 1 MCP client](/guides/mcp-install#tier-1-first-class-clients). ## Prerequisites * [Claude Code](https://docs.claude.com/en/docs/claude-code) installed and signed in. * Network access to `closient.com` (no VPN that blocks the domain). That's it — you do not need a Closient account, an API key, or a Stripe subscription for the public tools. The MCP server is hosted by Closient; nothing runs on your machine. ## Step 1 — install the server (\~10 seconds) Run once from any terminal: ```bash theme={null} claude mcp add closient --transport http https://www.closient.com/mcp/http ``` Verify: ```bash theme={null} claude mcp list ``` You should see `closient` in the output. If you don't, open a fresh Claude Code session — the registry is read at startup. ## Step 2 — verify the connection (\~5 seconds) In Claude Code, ask: > Use the Closient MCP server's `ping` tool and report what it > returned. Claude should call `ping` and reply with `pong`. If Claude can't find the tool, the server isn't registered for this session — see [Troubleshooting](/guides/mcp-install#troubleshooting) on the install page. ## Step 3 — validate a GTIN (\~15 seconds) > Use Closient to validate the GTIN `00614141123452`. Claude calls `validate_gtin` and reports something like: ```json theme={null} { "valid": true, "normalized": "00614141123452", "length": 14 } ``` Try an invalid one: > Validate `00000000000001` — is the check digit right? You should get `valid: false` with a check-digit error. ## Step 4 — resolve the GTIN to a destination (\~20 seconds) > Resolve `00614141123452` and tell me what page Closient would > send a scanner to. Claude calls `resolve_gtin` and reports the resolver's destination URL plus which rule scope matched (`PRODUCT`, `BRAND`, `ORGANIZATION`, or `GLOBAL`). For an unknown GTIN you'll see `fallback_applied: true` — Closient still returns a sensible default destination even when no rule matches. ## Step 5 — look up product data (\~20 seconds) > Look up product details for `00614141123452` and summarize the > brand and description. Claude calls `lookup_product` and surfaces the structured catalog row — name, brand, description, data source, and any developer metadata stored on the record. ## Step 6 — try the search tools (\~30 seconds) > Search Closient for "organic oat milk" and show the top three > brands. Claude calls `search_products`. Add a latitude/longitude pair and the results are boosted by proximity (50 km radius): > Same query, but near `40.7128, -74.0060`. Are any of those > available within 25 km of that point? Claude can chain `search_products` → `check_availability` to answer that in a single response. ## Step 7 — generate a QR URL (optional, requires auth) The first time you ask Claude to generate a QR URL: > Use Closient to generate the canonical Digital Link URL for > `00614141123452`. Claude calls `generate_qr_url` and gets back a `403` with a `WWW-Authenticate` header. Claude Code shows you an OAuth consent prompt that opens [`https://www.closient.com/o/authorize/`](https://www.closient.com/o/authorize/) in your browser. Approve the `qr:generate` scope, return to Claude, and the call retries automatically — Claude reports the canonical Digital Link URL ready to encode into a QR code. See [Authentication](/guides/mcp-authentication) for the full flow. ## What you just used | Tool | What it did | | - | - | | `ping` | Confirmed the server is reachable. | | `validate_gtin` | Verified GTIN structure and check digit. | | `resolve_gtin` | Looked up the destination Closient would send a scanner to. | | `lookup_product` | Pulled the structured catalog row. | | `search_products` | Natural-language search across the catalog. | | `check_availability` | Found nearby stores carrying the GTIN. | | `generate_qr_url` | (Authenticated) Built the canonical Digital Link URL. | Full schemas for every tool are in the [Tool Reference](/guides/mcp-tools). ## Where to next * [Install the MCP Server](/guides/mcp-install) — set up the server in Claude Desktop, Cursor, VS Code, Claude.ai, Windsurf, or any generic MCP client. * [Tool Reference](/guides/mcp-tools) — every tool's inputs and outputs, with examples. * [Authentication](/guides/mcp-authentication) — the OAuth 2.1 step-up flow that gates `generate_qr_url`. * [MCP Error Contract](/guides/mcp-errors) — how to handle failures. * [MCP Identity Propagation](/guides/mcp-identity) — agent vs. end-user actor in audit logs. # Anthropic Connectors Directory — Reviewer Guide Source: https://docs.closient.com/guides/mcp-reviewer-guide Step-by-step walkthrough for Anthropic reviewers vetting the Closient MCP server submission. This page is written for Anthropic's Connectors Directory review team. It assumes you have a Claude account and \~30 minutes. The reviewer credentials and the OAuth client\_id referenced below are shared with you privately in the submission form — not on this page. ## 1. What Closient is (90 seconds of context) Closient is a GS1 Digital Link resolver and product catalog. Brands register GTINs (the numbers behind every barcode), attach catalog data, and Closient turns any 2D barcode scan or HTTPS resolution request into a structured product response — name, brand, batch, lot, expiry, recall status, nearest stores carrying it. The Closient MCP server exposes that catalog and resolver to any MCP-compatible agent. The intended user is anyone whose work intersects the physical-goods supply chain: brand operators generating QR codes, retail-tech engineers building scanner apps, compliance analysts checking recall lists, and AI agents answering "is this barcode legitimate / what is this product / where can I buy it" questions. The MCP server is hosted by Closient — there is nothing to install locally. ## 2. Add the connector in Claude You can validate the connector from any first-class Claude surface. We recommend [claude.ai](https://claude.ai) because it exercises the full OAuth round-trip via the web flow. 1. Open [claude.ai](https://claude.ai) → **Settings → Connectors → Custom Connectors**. 2. Click **Add custom connector**. 3. Paste the production MCP URL: ``` https://www.closient.com/mcp/http ``` 4. Click **Connect**. Claude registers a client via RFC 7591 Dynamic Client Registration. No manual client\_id/secret entry is needed. The connector should appear with the full tool list — read tools plus OAuth-gated write tools (QR generation and the C-4038 brand-owner product-entry tools; see [MCP Tools Reference](/guides/mcp-tools) for the complete set). Discovery (`tools/list`) is unauthenticated, so this step succeeds without signing in. You will be prompted to sign in only when you exercise an authenticated tool such as `generate_qr_url` in [Section 5](#5-generate-qr-url-authenticated). This walkthrough covers the original eight read/QR tools reviewed at submission time; the product write tools added in C-4038 are documented separately and not yet folded into this numbered script. If the connector fails to register, see [Troubleshooting](#troubleshooting) at the bottom of this page. ## 3. Reviewer credentials The submission form contains the following privately: * **Closient account email** — the seeded reviewer mailbox * **Closient account password** * **Email-confirmation status** — already confirmed; no inbox round-trip needed * **OAuth fallback client\_id and client\_secret** — only needed if Dynamic Client Registration is disabled in your reviewer harness You do not need to share or rotate these credentials — the account is exclusively for Anthropic review and is reset between submission cycles. The account has staff flags that disable marketing emails and exempt it from the standard rate limits, so a 30-minute review window comfortably exercises every tool. ## 4. Sample prompts — exercise each tool Run these in order from a single Claude chat with the Closient connector enabled. Approximate runtime: 15 minutes including OAuth. The seeded sample product for this account is GTIN `00614141123452` (a real GS1 test code, owned by the GS1 US prefix range used for samples). Resolver rules, lot/expiry metadata, and a pre-generated QR code are seeded against this GTIN. ### 4.1 `validate_gtin` — structural check Prompt: > Use the Closient MCP server to validate GTIN `00614141123452`. Expected behaviour: Claude calls `validate_gtin`. The response reports the GTIN is valid, normalized to its 14-digit form, and the check digit verifies. ```json theme={null} { "valid": true, "normalized": "00614141123452", "length": 14 } ``` Negative case: > Validate `00000000000001`. Expected: `valid: false` with a check-digit-mismatch reason. ### 4.2 `resolve_gtin` — apply resolver rules Prompt: > Resolve GTIN `00614141123452` using Closient. Tell me what page a > scanner would land on and which rule scope matched. Expected behaviour: Claude calls `resolve_gtin`. The response contains a `destination_url`, the matched `scope` (one of `PRODUCT`, `BRAND`, `ORGANIZATION`, `GLOBAL`), and `fallback_applied: false`. The seeded resolver rule on this GTIN points at a sample product page on `www.closient.com`. ### 4.3 `lookup_product` — structured catalog row Prompt: > Look up product details for `00614141123452` and summarize the > brand, description, and any developer metadata. Expected behaviour: Claude calls `lookup_product` and surfaces the seeded record — sample brand name, three-sentence description, ingredient list (this seed is a food-category product), and a `developer_metadata` block with a `lot` and `expiry` set. ### 4.4 `search_products` — natural-language search Prompt: > Search Closient for "organic oat milk" and show me the top three > products with brand and GTIN. Expected behaviour: Claude calls `search_products`. The seeded catalog contains at least one match (the sample is a food-category product). Top results are returned with brand, GTIN, and a short description. The reviewer account is exempt from per-IP search rate limits, so repeated queries during the review window do not 429. Optional geo boost: > Same query, near `40.7128, -74.0060` within 25 km. Closient adds proximity boosting against seeded store-location data within a 50 km radius. ### 4.5 `get_product_detail` — rich record Prompt: > Get the full product detail for `00614141123452` — include > attributes, images, ingredients, and nutrition. Expected behaviour: Claude calls `get_product_detail` and returns the full structured record. The seed includes one product image (hosted on `media.closient.com`), an ingredient list, and a basic nutrition panel. ### 4.6 `compare_products` — side-by-side The seeded account contains two additional products related to the primary sample (different sizes / variants). GTINs are listed on the seeded product's detail page. Prompt (substitute the second GTIN from the previous step's response): > Compare `00614141123452` to `` — list the > differences in nutrition, size, and price. Expected behaviour: Claude calls `compare_products` and produces a two-column comparison. Field-level differences are flagged. ### 4.7 `check_availability` — nearby stores Prompt: > Are there any stores within 25 km of `40.7128, -74.0060` that > carry `00614141123452`? Expected behaviour: Claude calls `check_availability`. The seed includes two sample store locations within 25 km of New York for this GTIN. The response lists store name, address, and approximate distance. ### 4.8 `generate_qr_url` — authenticated This is the one tool that requires OAuth. It is also the differentiator vs. generic GS1 lookups, so please do exercise it. Prompt: > Use Closient to generate a canonical GS1 Digital Link URL for > GTIN `00614141123452` with lot `LOT-2026-05` and expiry > `2027-12-31`. Expected behaviour: 1. Claude calls `generate_qr_url` without a token. 2. Closient returns `403 Forbidden` with a `WWW-Authenticate` header pointing at the Closient OAuth authorization server. 3. Claude shows you an OAuth consent screen at `https://www.closient.com/o/authorize/`. The reviewer account is already signed in from the connector setup; you only need to click **Approve** for the `qr:generate` scope. 4. Claude retries the call and reports the canonical Digital Link URL — something like: ``` https://id.closient.com/01/00614141123452/10/LOT-2026-05/?17=271231 ``` The URL is conformant with the [GS1 Digital Link 1.4.1 grammar](https://ref.gs1.org/standards/digital-link/) — plug it into any GS1-conformant resolver (including Closient's own public resolver) and you'll be redirected to the same destination that `resolve_gtin` returned in step 4.2. A pre-generated QR code for this exact URL is attached to the seeded product record so you can verify the output round-trips through a barcode scanner if you have one handy. ## 5. Expected results summary | Step | Tool | Expected outcome | | - | - | - | | 4.1 | `validate_gtin` | `valid: true`, normalized 14-digit GTIN | | 4.2 | `resolve_gtin` | `destination_url` set, `scope` populated | | 4.3 | `lookup_product` | Seeded brand, description, lot, expiry | | 4.4 | `search_products` | At least one organic-food result | | 4.5 | `get_product_detail` | Image, ingredients, nutrition panel | | 4.6 | `compare_products` | Two-column diff with field-level flags | | 4.7 | `check_availability` | Two NYC-area sample stores | | 4.8 | `generate_qr_url` | GS1 Digital Link URL after OAuth approval | All eight prompts should complete successfully in under 15 minutes of wall-clock time, including the OAuth round-trip. ## 6. Known limitations These are intentional and out of scope for this submission: * **`generate_qr_url` requires a paid Closient plan in production.** The reviewer account is a staff-flagged exception with the same capability set as a paid Pro account — you will not hit a paywall. * **Image attachments** on `get_product_detail` return signed URLs with a 15-minute expiry. If you let the response sit for longer and then click the URL, you may need to re-call the tool. * **`search_products` geographic boosting** only applies inside the US right now. International coordinates return non-geo-boosted results. * **Tool annotations** (`readOnlyHint`, `destructiveHint`, `idempotentHint`) are populated for every registered tool — see [MCP Tool Annotations](/guides/mcp-tool-annotations) for the full per-tool table and semantics. Of the eight tools in this walkthrough, `generate_qr_url` is annotated as a writeable, non-destructive operation; the other seven are read-only. The C-4038 product write tools (not covered by this walkthrough) include several `destructiveHint=true` tools — see the annotations page. * **Audit logging.** Reviewer-session activity is captured to a staff-only audit log for our own learning — there is no PII beyond the seeded account's own metadata. ## 7. Troubleshooting | Symptom | Likely cause | | - | - | | Connector won't register | Confirm the URL is `https://www.closient.com/mcp/http` (note the `/http` suffix — the bare `/mcp` is a legacy SSE endpoint and rejects Streamable HTTP clients). | | `tools/list` is empty | Claude is talking to the SSE endpoint by mistake. Re-add with the URL above. | | OAuth consent screen never appears | Your reviewer harness may not honour `WWW-Authenticate`. Use the fallback OAuth client\_id / client\_secret from the submission form to mint a token manually — see [MCP Authentication](/guides/mcp-authentication). | | 429 Too Many Requests | The reviewer account should be rate-limit-exempt; if you see a 429, the staff flag may not have propagated. Email the contact below. | | Anything else | Email the contact in [Section 8](#8-contact). | ## 8. Contact For any question that blocks the review, email [christian@closient.com](mailto:christian@closient.com). Expected response time during a review window: under 4 hours during US/EU business hours, under 24 hours otherwise. The submission packet for cross-reference: * [Install the MCP Server](/guides/mcp-install) — the public install page that your users will land on if Closient is accepted. * [MCP Quickstart](/guides/mcp-quickstart) — the public quickstart. * [Tool Reference](/guides/mcp-tools) — input/output schemas for every tool. * [MCP Authentication](/guides/mcp-authentication) — the OAuth 2.1 step-up flow that gates `generate_qr_url`. * [MCP Tool Annotations](/guides/mcp-tool-annotations) — how Closient declares read/write/idempotency hints. * [MCP Error Contract](/guides/mcp-errors) — JSON-RPC + RFC 9457 error semantics. * [MCP Identity Propagation](/guides/mcp-identity) — agent vs. end-user actor in audit logs. Thank you for reviewing. # MCP Tool Annotations Source: https://docs.closient.com/guides/mcp-tool-annotations How Closient's MCP tools advertise their side-effect profile — readOnlyHint and destructiveHint. Every tool exposed by the Closient MCP server carries a structured `annotations` object that tells the host (Claude, Claude Desktop, the Connectors Directory validator, third-party MCP clients) whether the tool mutates state and whether repeated calls are safe to retry. This is required by the [MCP specification][spec] and by the [Anthropic Connectors Directory submission process][submission] — servers that omit the annotations are rejected at submission time. [spec]: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-annotations [submission]: https://claude.com/docs/connectors/building/submission ## Annotation fields Each tool sets a subset of the following hints. They are advisory — agents and hosts use them to decide whether to gate a call behind a confirmation prompt, or whether retrying after a transport error is safe. | Field | Meaning | | - | - | | `readOnlyHint` | `true` → the tool performs no state changes. Hosts may invoke without confirmation. | | `destructiveHint` | `true` → the tool may *destructively* modify user-visible state (delete, overwrite). | | `idempotentHint` | `true` → repeated calls with identical arguments yield identical effects. | | `openWorldHint` | `false` → the tool reads only from Closient-controlled data (catalog, resolver rules). | | `title` | Human-readable label for UIs that prefer a polished name over the snake-case tool name. | `readOnlyHint=true` and `destructiveHint=true` are mutually exclusive. A CI test (`test_destructive_hint_implies_not_read_only`) enforces this. ## Closient's policy We use a narrow reading of `destructiveHint`: it means **destructive** changes — deletes, overwrites, revocations, cancellations — not "any mutation." A tool that *adds* a record without erasing prior state is flagged `readOnlyHint=false` *without* `destructiveHint`. The trade-off: `destructiveHint=true` causes most hosts to render an extra "are you sure?" prompt before the call. That's the right UX before deleting a brand claim or canceling a subscription, and the wrong UX before generating a QR code or recording an audit event. We reserve the hint for the former. ## Per-tool annotations | Tool | `readOnlyHint` | `destructiveHint` | `idempotentHint` | Notes | | - | - | - | - | - | | `ping` | `true` | — | `true` | Health probe; no auth, no I/O. | | `validate_gtin` | `true` | — | `true` | Pure validation — no DB access. | | `validate_digital_link` | `true` | — | `true` | Pure validation — no DB access. | | `resolve_gtin` | `true` | — | `true` | DB read against the resolver-rule hierarchy. | | `lookup_product` | `true` | — | `true` | DB read against the product catalog. | | `search_products` | `true` | — | `true` | Hybrid semantic + lexical catalog search. | | `get_product_detail` | `true` | — | `true` | Rolled-up product read (images, ingredients, nutrition). | | `compare_products` | `true` | — | `true` | Side-by-side read across 2-5 GTINs. | | `check_availability` | `true` | — | `true` | Nearby-offer read via PostGIS. | | `generate_qr_url` | `false` | — | `true` | Builds a Digital Link URL and records the call via `@user_action`. State-mutating but **not** destructive. | | `create_product` | `false` | — | `false` | Creates a new catalog row; not idempotent (a second call with the same GTIN conflicts). | | `update_product` | `false` | — | `true` | Updates scalar fields on an existing row. Additive, not destructive. | | `upload_product_image` | `false` | — | `false` | Adds a new image; does not overwrite existing ones. | | `delete_product_image` | `false` | `true` | `false` | Deletes user-visible data. | | `upload_product_document` | `false` | — | `false` | Adds a new document. | | `update_product_document` | `false` | — | `false` | Edits metadata / optionally replaces the file on an existing document. | | `delete_product_document` | `false` | `true` | `false` | Deletes user-visible data. | | `add_product_video` | `false` | — | `false` | Adds a new video link. | | `update_product_video` | `false` | — | `false` | Edits an existing video's fields. | | `delete_product_video` | `false` | `true` | `false` | Deletes user-visible data. | | `set_product_nutrition` | `false` | `true` | `true` | Wholesale-replaces the nutrition panel — rows omitted from the call are deleted. | | `set_product_ingredients` | `false` | `true` | `true` | Wholesale-replaces the parsed ingredient list. | `generate_qr_url` was the lone non-read-only tool through C-344; C-4038 added the brand-owner product write tools above, five of which (`delete_product_image`, `delete_product_document`, `delete_product_video`, `set_product_nutrition`, `set_product_ingredients`) are the first tools to carry `destructiveHint=true` — they either delete a record outright or wholesale-replace a panel/list, dropping anything the caller didn't re-list. The current destructive set is pinned by `test_destructive_tools_match_pinned_set` in `backend/closient/tests/test_mcp_tool_annotations.py` — the test fails loudly if a future contributor flips a bit without updating policy. ## Wire format Annotations are serialized as a nested object on each entry of the `tools/list` response. Pydantic's `exclude_none=True` mode drops fields that weren't set, so the wire payload is minimal: ```json theme={null} { "name": "lookup_product", "title": "Look Up Product", "description": "Look up structured product data by GTIN. ...", "inputSchema": { "type": "object", "properties": { ... } }, "annotations": { "title": "Look Up Product", "readOnlyHint": true, "idempotentHint": true, "openWorldHint": false } } ``` You can verify the shape against a running server with the MCP Inspector or a one-off `curl`: ```bash theme={null} curl -sS https://www.closient.com/mcp/http \ -H 'Accept: application/json, text/event-stream' \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' ``` ## Adding a new tool When you register a new tool with `@mcp.tool()` in `backend/closient/mcp_tools.py`: 1. Pass `annotations=ToolAnnotations(...)` with at least one of `readOnlyHint` / `destructiveHint` set. 2. Add a row to the per-tool table above and to the parametrized `test_tool_read_only_hint_matches_policy` test. 3. If the tool is destructive, update `test_destructive_tools_match_pinned_set` so the assertion still reflects ground truth. The CI test `test_every_registered_tool_has_annotations` is the safety net — a missing annotation fails the suite before it can ship. # MCP Tool Reference Source: https://docs.closient.com/guides/mcp-tools Input schemas, output shapes, and example calls for every tool exposed by the Closient MCP server. This page is the authoritative reference for the tools the Closient MCP server registers via `tools/list`. Every tool described here is a thin wrapper around the same service-layer functions used by the REST API — if a call would succeed via the REST API for the authenticated principal, it succeeds via MCP, and vice versa. For installation see [Install the MCP Server](/guides/mcp-install). For error semantics see [MCP Error Contract](/guides/mcp-errors). ## Conventions * **GTIN inputs** accept GTIN-8, GTIN-12, GTIN-13, or GTIN-14 with or without leading zeros. Closient normalizes everything to GTIN-14 internally — the normalized form is echoed back in the response. * **Optional fields** in the input schema default to `None`/`null` and are omitted from the resulting query when not supplied. * **Errors** are returned as JSON-RPC error objects with an RFC 9457 Problem Details payload in `data`. See [MCP Error Contract](/guides/mcp-errors) for the code table. * **Tool annotations** (`readOnlyHint`, `idempotentHint`, etc.) are documented separately in [MCP Tool Annotations](/guides/mcp-tool-annotations). ## `ping` Health probe. Returns the string `"pong"`. Useful from an agent preamble to verify the MCP connection is live before issuing real calls. **Input:** no arguments. **Output:** `string` — always `"pong"`. **Auth:** none. **Example:** ```json theme={null} { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "ping", "arguments": {} } } ``` ```json theme={null} { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "pong" } ] } } ``` ## `validate_gtin` Pure GTIN validation — check digit, length, format. No catalog lookup, no auth. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A GTIN-8/12/13/14 string. Leading zeros are accepted but not required. | **Output:** | Field | Type | Description | | - | - | - | | `valid` | `boolean` | Overall validity. | | `error` | `string` \| `null` | Human-readable reason if invalid. | | `normalized` | `string` \| `null` | Zero-padded GTIN-14 if valid. | | `length` | `integer` | Original GTIN length (before normalization). | **Example:** ```json theme={null} { "name": "validate_gtin", "arguments": { "gtin": "614141123452" } } ``` ```json theme={null} { "valid": true, "error": null, "normalized": "00614141123452", "length": 12 } ``` ## `validate_digital_link` GS1 Digital Link conformance check. Grades a URI against the spec and returns every issue found, plus the parsed primary key, qualifiers and data attributes. No catalog lookup, no auth. The same engine backs the keyless HTTP endpoint `GET /resolver/api/v1/public/gs1/validate`. | Field | Type | Required | Description | | - | - | - | - | | `uri` | `string` | yes | The candidate Digital Link URI, 1 to 4096 characters. | **Output:** | Field | Type | Description | | - | - | - | | `uri` | `string` | The URI that was graded. | | `is_conformant` | `boolean` | `true` when no error-severity issue was found. | | `issues` | `array` | Each `{code, severity, message}`. Empty when conformant. | | `scheme` | `string` \| `null` | URI scheme. | | `host` | `string` \| `null` | URI host. | | `primary` | `object` \| `null` | Primary key as `{code, name, kind, value, description}`. | | `qualifiers` | `array` | Qualifier AIs, same shape as `primary`. | | `data_attributes` | `array` | Each `{code, name, value, format_error}`. | | `canonical_path` | `string` \| `null` | Normalized path, or `null` when no primary key was found. | A non-conformant URI is a normal result with `is_conformant: false`, not an error. An empty or over-long `uri` returns error `-32101`. **Example:** ```json theme={null} { "name": "validate_digital_link", "arguments": { "uri": "https://id.gs1.org/01/09506000164908/10/LOT1?17=261231" } } ``` ```json theme={null} { "uri": "https://id.gs1.org/01/09506000164908/10/LOT1?17=261231", "is_conformant": true, "issues": [], "scheme": "https", "host": "id.gs1.org", "primary": { "code": "01", "name": "GTIN", "kind": "primary", "value": "09506000164908", "description": "Global Trade Item Number" }, "qualifiers": [ { "code": "10", "name": "Batch / Lot", "kind": "qualifier", "value": "LOT1", "description": "Batch or lot number" } ], "data_attributes": [ { "code": "17", "name": "Expiration date (YYMMDD)", "value": "261231", "format_error": null } ], "canonical_path": "/01/09506000164908/10/LOT1" } ``` ## `resolve_gtin` Apply the [GS1 Digital Link resolver-rule hierarchy](/guides/digital-link-resolution) and return the destination the resolver would redirect to for a given GTIN. Use this to learn what page (or external URL) a brand has configured for a product without actually following the redirect. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN. | | `link_type` | `string` | no | GS1 link-type CURIE such as `gs1:pip` or `gs1:recallStatus`. When omitted, the default link-type rule wins. | **Output:** | Field | Type | Description | | - | - | - | | `destination_url` | `string` | Where the resolver would redirect. | | `destination_type` | `string` | `HOSTED_PAGE` or `CUSTOM_URL`. | | `fallback_applied` | `boolean` | `true` when no rule matched and the default was used. | | `product_found` | `boolean` | Whether a Product row exists for the GTIN. | | `matched_rule_scope` | `string` \| `null` | `PRODUCT`, `BRAND`, `ORGANIZATION`, `GLOBAL`, or `null`. | **Example:** ```json theme={null} { "name": "resolve_gtin", "arguments": { "gtin": "00614141123452", "link_type": "gs1:pip" } } ``` ```json theme={null} { "destination_url": "https://www.closient.com/01/00614141123452", "destination_type": "HOSTED_PAGE", "fallback_applied": false, "product_found": true, "matched_rule_scope": "PRODUCT" } ``` ## `lookup_product` Look up the structured catalog record for a GTIN. Returns `found: false` (rather than an error) when the GTIN is unknown so agents can branch on the boolean without try/except plumbing. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN. | **Output:** | Field | Type | Description | | - | - | - | | `found` | `boolean` | Whether the GTIN is in the catalog. | | `gtin` | `string` \| `null` | Normalized GTIN-14 if found. | | `name` | `string` \| `null` | Product name. | | `brand` | `string` \| `null` | Brand name. | | `description` | `string` \| `null` | Product description. | | `data_source` | `string` \| `null` | Origin of the record (`manual`, `off`, etc.). | | `metadata` | `object` | Flat string-to-string developer metadata. `{}` when unset. | **Example:** ```json theme={null} { "name": "lookup_product", "arguments": { "gtin": "00614141123452" } } ``` ```json theme={null} { "found": true, "gtin": "00614141123452", "name": "Sample Cereal", "brand": "Acme Foods", "description": "Whole-grain breakfast cereal.", "data_source": "manual", "metadata": { "internal_sku": "ACM-1234" } } ``` ## `search_products` Natural-language search across the Closient catalog. Combines pgvector semantic search with PostgreSQL full-text lexical search and (optionally) PostGIS-based geographic boosting. | Field | Type | Required | Description | | - | - | - | - | | `query` | `string` | yes | Free-text search string. Example: `"organic oat milk"`. | | `filters` | `object` | no | Flat `{key: value}` dict applied to structured metadata. Useful keys: `brand`, `category_id`, `country_of_origin`. | | `geo_lat` | `number` | no | Latitude (WGS84) — requires `geo_lng`. | | `geo_lng` | `number` | no | Longitude (WGS84). | | `limit` | `integer` | no | Max results. Defaults to `20`. | When both `geo_lat` and `geo_lng` are present, results within 50 km are boosted by proximity. **Output:** | Field | Type | Description | | - | - | - | | `items` | `array` | Ordered by relevance (highest score first). Each item has `gtin`, `brand`, `category`, `net_content`, `country_of_origin`, `price_min`, `price_max`, `score`, `distance_km`. | | `total` | `integer` | Number of items returned. | | `query` | `string` | Echo of the input query. | **Example:** ```json theme={null} { "name": "search_products", "arguments": { "query": "gluten-free pasta", "geo_lat": 40.7128, "geo_lng": -74.0060, "limit": 5 } } ``` ## `get_product_detail` Comprehensive product record — attributes, images, ingredients, nutrition facts. Use after `search_products` (or whenever a GTIN is already in hand) to fetch everything in one round-trip. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN. | **Output:** | Field | Type | Description | | - | - | - | | `found` | `boolean` | `false` for unknown GTINs; other fields are then null/empty. | | `product` | `object` \| `null` | Compact summary (gtin, name, brand, category, description, image\_url, net\_content, country\_of\_origin). | | `images` | `array` | `{url, title, description, priority, is_primary}` rows ordered by priority then creation time. | | `ingredients` | `array` | `{name, percentage_of_weight, priority}` rows. | | `nutrition` | `object` \| `null` | `{serving_size, servings_per_container, label_format, nutrients: [{name, amount, unit, daily_value_pct}, ...]}` or `null`. | | `hosted_page_url` | `string` | The canonical hosted-page path (`/01/{gtin14}`). | ## `compare_products` Side-by-side comparison of 2-5 products by GTIN. Unknown GTINs appear as `found: false` columns rather than raising — partial comparisons are useful. | Field | Type | Required | Description | | - | - | - | - | | `gtins` | `array` | yes | 2-5 GTINs. Order is preserved in the response. | **Output:** | Field | Type | Description | | - | - | - | | `items` | `array` | One column per requested GTIN — `{found, gtin, product}`. | | `attributes` | `array` | Aligned attribute rows — `{name, values}` where `values[i]` corresponds to `items[i]`. Currently: `brand`, `category`, `net_content`, `country_of_origin`, `price_min`, `price_max`. | **Example:** ```json theme={null} { "name": "compare_products", "arguments": { "gtins": ["00614141123452", "00614141999999"] } } ``` ## `check_availability` Find nearby physical stores carrying a specific product. Returns active in-store offers within `radius_km`, ordered by ascending distance. When no offers exist for the product, the response is structurally well-formed (empty `locations`) rather than an error so agents can confidently say "no nearby availability" without exception handling. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN. | | `lat` | `number` | yes | Latitude (WGS84) of the search origin. | | `lng` | `number` | yes | Longitude (WGS84). | | `radius_km` | `number` | no | Maximum search radius in km. Defaults to `25`. | **Output:** | Field | Type | Description | | - | - | - | | `gtin` | `string` | Normalized GTIN-14 echoed for convenience. | | `radius_km` | `number` | Echo of the search radius. | | `locations` | `array` | `{retailer, store_name, address, city, region, postal_code, distance_km, price, currency, inventory_status}` rows. | | `total` | `integer` | Number of locations returned (may be `0`). | ## `generate_qr_url` Build the canonical [GS1 Digital Link](/guides/digital-link-resolution) URL for a GTIN, optionally with batch/lot and serial qualifiers. The returned URL is what should be encoded into a 2D barcode (QR or Data Matrix). When scanned, the URL hits Closient's resolver at `/01/{gtin14}[/10/{batch}][/21/{serial}]` and returns the linkset or redirects to the destination per the resolver rules. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN — normalized to GTIN-14 in the URL. | | `batch` | `string` | no | Batch / lot identifier (GS1 AI 10). | | `serial` | `string` | no | Unique serial (GS1 AI 21). | **Output:** | Field | Type | Description | | - | - | - | | `qr_url` | `string` | The canonical Digital Link URL. | | `gtin14` | `string` | Normalized GTIN-14 used in the URL. | | `host` | `string` | Host portion of the URL (from `SITE_URL`). | **Auth:** OAuth 2.0 bearer token with `qr:generate` scope. See [Authentication](/guides/mcp-authentication). Anonymous calls and calls with a token that doesn't include `qr:generate` return JSON-RPC error code `-32002` (`forbidden`). **Example:** ```json theme={null} { "name": "generate_qr_url", "arguments": { "gtin": "00614141123452", "batch": "LOT-A1", "serial": "SN-0001" } } ``` ```json theme={null} { "qr_url": "https://www.closient.com/01/00614141123452/10/LOT-A1/21/SN-0001", "gtin14": "00614141123452", "host": "www.closient.com" } ``` ## Product write tools Every tool in this section requires an OAuth 2.0 bearer token issued via the **`authorization_code`** grant (an end-user token — `client_credentials` tokens have no user to attribute writes to and are rejected). The caller must additionally hold organization membership with the permission noted per tool: **CONTRIBUTE** (OWNER, MANAGER, or EDITOR role) for most tools, or **OWNER/MANAGER** specifically for `create_product`. See [Authentication](/guides/mcp-authentication) for the OAuth flow and [MCP Identity Propagation](/guides/mcp-identity) for how the acting user is threaded through to the audit trail. Two scopes gate this family: `products:write` (`create_product`, `update_product`) and `dashboard:write` (everything else — images, documents, videos, nutrition, ingredients). ### `create_product` Create a new product and claim it for an organization — the first step of brand-owner product entry for a GTIN not yet in Closient's catalog. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A GTIN-8/12/13/14, leading zeros optional. | | `product_name` | `string` | yes | Display name. | | `organization_id` | `string` | no | The organization's public identifier — either the 22-char shortuuid form (as returned everywhere else in the API) or a dashed UUID. Omit to use the caller's org automatically (only unambiguous with exactly one OWNER/MANAGER membership). | | `description` | `string` | no | Long-form description. | | `country_of_origin` | `string` | no | ISO 3166-1 alpha-2 code. | | `net_content` | `string` | no | Free text, e.g. `"340g"`. | | `metadata` | `object` | no | Stripe-style flat developer metadata. | **Output:** compact product summary — `{gtin, name, brand, category, description, image_url, net_content, country_of_origin}`. **Auth:** `products:write` scope; caller must be OWNER or MANAGER of the target organization. ### `update_product` Update basic info, structured net content, dimensions/weight, or GPC classification on a claimed product — all share a single field allowlist (`Product.BRAND_EDITABLE_FIELDS`), so one tool covers all three. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN. | | `updates` | `object` | yes | Flat `{field: value}` map. Unrecognized keys are ignored. `brand`/`manufacturer` accept a plain name string (looked up case-insensitively, created if no match). Editable fields: `product_name`, `brand`, `category_id`, `description`, `usage_instructions`, `country_of_origin`, `primary_language`, `manufacturer`, `brand_product_page_url`, `net_content_value`, `net_content_uom`, `length`/`width`/`height`/`depth` + their `*_uom`, `gross_weight`/`net_weight` + their `*_uom`, `gpc_segment_code`/`gpc_family_code`/`gpc_class_code`/`gpc_brick_code`. | **Output:** compact product summary (same shape as `create_product`). **Auth:** `products:write` scope; caller needs CONTRIBUTE permission on the owning organization. **Example:** ```json theme={null} { "name": "update_product", "arguments": { "gtin": "00614141123452", "updates": { "net_weight": "340", "net_weight_uom": "GRM", "gpc_brick_code": "10000045" } } } ``` ### `upload_product_image` Upload an image and attach it to a claimed product. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | A valid GTIN. | | `image_base64` | `string` | yes | Base64-encoded image bytes. An optional `data:image/...;base64,` prefix is tolerated. | | `filename` | `string` | yes | Used to name the stored file; actual format is sniffed from the bytes. | | `title` | `string` | no | | | `is_primary` | `boolean` | no | Marks this the hero image, unsetting any existing primary. Defaults to `false`. | **Output:** `{id, url, title, is_primary, priority}`. **Auth:** `dashboard:write` scope; caller needs CONTRIBUTE permission. ### `delete_product_image` Delete an image from a claimed product. Immediate and permanent. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `image_id` | `string` | yes | The `id` from `upload_product_image` or `get_product_detail`. | **Output:** `{deleted: true, image_id}`. ### `upload_product_document` Upload a PDF (spec sheet, safety data sheet, certificate) and attach it to a claimed product. Non-PDF content is rejected. Dispatches an async metadata/thumbnail extraction task; the response reflects the upload itself, not the enrichment (visible on a subsequent `get_product_detail`). | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `file_base64` | `string` | yes | Base64-encoded PDF bytes. | | `filename` | `string` | yes | | | `title` | `string` | no | | | `description` | `string` | no | | **Output:** `{id, url, title, description, file_size_bytes}`. ### `update_product_document` Partially update a document's title/description, and optionally replace its file, on a claimed product. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `document_id` | `string` | yes | | | `title` | `string` | no | Omit/null to leave unchanged. | | `description` | `string` | no | Omit/null to leave unchanged. | | `file_base64` | `string` | no | Supply together with `filename` to replace the file, or omit both. | | `filename` | `string` | no | | **Output:** `{id, url, title, description, file_size_bytes}`. ### `delete_product_document` Delete a document from a claimed product. Immediate and permanent. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `document_id` | `string` | yes | | **Output:** `{deleted: true, document_id}`. ### `add_product_video` Attach a YouTube video link to a claimed product. YouTube only — Closient does not host video files. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `youtube_url` | `string` | yes | Any URL format YouTube supports; rejected if no video ID can be extracted. | | `title` | `string` | no | | | `description` | `string` | no | | | `priority` | `integer` | no | Lower sorts first. Defaults to `0`. | **Output:** `{id, youtube_url, video_id, title, description, priority}`. ### `update_product_video` Partially update a video attached to a claimed product. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `video_id` | `string` | yes | | | `updates` | `object` | yes | Flat map of any of `youtube_url`, `title`, `description`, `priority`. | **Output:** same shape as `add_product_video`. ### `delete_product_video` Delete a video from a claimed product. Immediate and permanent. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `video_id` | `string` | yes | | **Output:** `{deleted: true, video_id}`. ### `set_product_nutrition` Create or **wholesale-replace** a product's entire nutrition-facts panel — the header plus every nutrient row — in one call. Reconciles `nutrients` against what's already stored by `nutrient_type`: matched rows are updated, new types are created, and rows **omitted from this call are deleted**. This is not a merge/patch operation — always pass the complete label. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `serving_size` | `string` | yes | Free text as printed, e.g. `"1 cup (240ml)"`. | | `nutrients` | `array` | yes | Each row: `{nutrient_type, amount, unit, daily_value_pct?}`. `nutrient_type` is one of the model's `NutrientType` choices (`CALORIES`, `TOTAL_FAT`, `SATURATED_FAT`, `TRANS_FAT`, `CHOLESTEROL`, `SODIUM`, `TOTAL_CARBS`, `DIETARY_FIBER`, `TOTAL_SUGARS`, `ADDED_SUGARS`, `PROTEIN`, `VITAMIN_D`, `CALCIUM`, `IRON`, `POTASSIUM`, ...). `unit` is one of `G`, `MG`, `MCG`, `KCAL`, `KJ`, `IU`, `ML`. Duplicate `nutrient_type` values in the same call are rejected. | | `serving_size_g` | `number` | no | | | `servings_per_container` | `number` | no | | | `label_format` | `string` | no | One of `US_FDA` (default), `EU`, `CA_CFIA`, `AU_NZ`, `UK`. | **Output:** `{serving_size, servings_per_container, label_format, nutrients: [{nutrient_type, amount, unit, daily_value_pct}, ...]}`. **Example:** ```json theme={null} { "name": "set_product_nutrition", "arguments": { "gtin": "00614141123452", "serving_size": "1 cup (240ml)", "nutrients": [ { "nutrient_type": "CALORIES", "amount": 150, "unit": "KCAL" }, { "nutrient_type": "SODIUM", "amount": 200, "unit": "MG" } ] } } ``` ### `set_product_ingredients` Set a product's ingredient statement from raw label text — Closient parses it into structured substance mentions automatically (splitting out a "may contain" / cross-contamination section, resolving each mention to a known substance where possible). **Wholesale-replaces** any previously parsed ingredient list for the product; there is no incremental "add one ingredient" tool over MCP today. Mirrors the `ingredients` field on the public `PATCH /products/{gtin}` REST endpoint. | Field | Type | Required | Description | | - | - | - | - | | `gtin` | `string` | yes | | | `raw_text` | `string` | yes | The ingredient statement exactly as printed on the label. | **Output:** `{raw_text, ingredients: [{name, raw_text, match_type, confidence, is_sub_ingredient}, ...]}`. `name` is `null` when the parser couldn't resolve a mention (`match_type: "UNRESOLVED"`). **Example:** ```json theme={null} { "name": "set_product_ingredients", "arguments": { "gtin": "00614141123452", "raw_text": "Water, Sugar, Cocoa (Processed with Alkali), Salt. Contains: Milk. May Contain: Tree Nuts." } } ``` ## See also * [Install the MCP Server](/guides/mcp-install) * [Quickstart — 2 minutes to first resolution](/guides/mcp-quickstart) * [Authentication](/guides/mcp-authentication) * [MCP Error Contract](/guides/mcp-errors) * [MCP Identity Propagation](/guides/mcp-identity) * [MCP Tool Annotations](/guides/mcp-tool-annotations) # Embeddable product information panel Source: https://docs.closient.com/guides/pip-embed Drop Closient's recall, allergen, certification and ingredient data straight into your own product detail pages. The product information panel is a small script you add to your own product detail pages. It renders recall status, allergens, certifications and the printed ingredient label for a GTIN, served from Closient and kept current without you republishing anything. It is built for the critical rendering path: about 3.5 KB gzipped, loaded asynchronously, with its space reserved before the data arrives so it never pushes your page content around. ## Add it to a page ```html theme={null} ``` The panel renders immediately after the ` ``` ### Configuration | Attribute | Values | Default | Meaning | | - | - | - | - | | `data-gtin` | GTIN-8/12/13/14, separators allowed | — | **Required.** The product to render. | | `data-theme` | `light`, `dark`, `auto` | `light` | `auto` follows the visitor's OS colour scheme. | | `data-branding` | `on`, `off` | `on` | `off` removes the "Powered by Closient" mark. The link to the full product page stays. | The panel inherits your page's font stack and sizes itself to its container, so it takes on your typography without shipping a webfont of its own. ## Content Security Policy If your site sends a CSP, the script embed needs two directives: ``` script-src https://www.closient.com; connect-src https://www.closient.com; ``` If you want the product photo to render, add Closient's media origin to `img-src` as well. That is a **different host** from `www.closient.com` — product media is served from its own storage domain — so `script-src` and `connect-src` do not cover it. Read the `image_url` field from the JSON response for a product you carry and take the origin from there; the panel omits the image cleanly if the request is blocked, so this directive is optional. ### If you cannot allow a third-party script Use the iframe instead. It renders the same panel from Closient's own origin and needs only `frame-src`: ```html theme={null} ``` ``` frame-src https://www.closient.com; ``` The trade-off is height. A framed document cannot resize its own frame, so you set the height and the panel scrolls inside it — where the script embed grows to fit its content. Prefer the script embed when your CSP allows it. ## What the panel sends Nothing about your visitors, to anyone. * **No cookies.** The panel's request is made with `credentials: 'omit'`, and no response it receives sets a cookie. Closient cannot recognise a visitor across your pages or anyone else's. * **No third-party requests.** Every request the panel makes goes to Closient — the origin it was loaded from, plus Closient's own media domain when the product has a photo. No analytics, no fonts, no third-party CDN, no map tiles. * **No client-side tracking.** The panel fires no analytics event of any kind. Closient counts panel renders server-side, in aggregate, and those counts are tagged separately from real QR scans so they never inflate a brand's scan numbers. This is the same commitment the locator widgets make — see [widget privacy](/concepts/store-locator-widget-privacy) for the full statement your DPA can cite. ## What the panel shows Sometimes less than the full record, and always for a reason. Every response carries a `disclosure` value that says which case applies: | `disclosure` | What renders | Why | | - | - | - | | `full` | Everything: name, brand, image, recall status, allergens, certifications, ingredients. | The claim on this GTIN is verified. | | `unverified` | The GTIN and recall status only. | Nobody has proven they own this barcode. Closient will not republish an unverified claimant's product name or claims as fact — but a recall reaches your shoppers regardless. | | `suspended` | Name, image and recall status. | The owning brand's account is closed and in its deletion window. Safety information keeps flowing; brand content does not. | | `age_restricted` | Name and recall status, with a link out. | An age gate that your page could hide with CSS would not be a gate. The full record stays behind the hosted page, which does gate. | An empty `certifications` list on a `full` response means the product has none. On any other response it means Closient is not asserting them. Read `disclosure` before drawing a conclusion from an empty field. If Closient has no record of the GTIN at all, the panel renders nothing — not an error box. An empty compliance panel is a worse shopping experience than no panel. ## The URL is the contract `/widget/v1/pip-panel.js` is a version you pin, not a release number. Closient ships fixes and improvements by replacing the asset behind that URL, so you get them without touching your theme. The `v1` changes only if the embed's own contract changes — attribute names, element name, the shape of what renders — and a `v2` would be published alongside `v1`, never in place of it. Do not deep-link to the underlying static asset or to the JSON endpoint's internals; those are free to move. ## Reading the data directly If you would rather render the panel yourself, the same data is available as JSON. It is anonymous, open-CORS and cacheable: ```bash theme={null} curl https://www.closient.com/products/api/v1/public/pip/00614141000036 ``` The schema is `EmbedPanelOut` under **PIP Embed (Public)** in the [Products API reference](/api-reference/products). Responses carry `Cache-Control: public, max-age=300, s-maxage=300` — please respect it rather than polling. # Returns Verification Source: https://docs.closient.com/guides/returns-verification Verify a serialized unit was actually sold by your organization, and hasn't already been returned — one call for the return desk. Returns Verification answers a narrower, sharper question than a general product lookup: **did this organization sell this exact unit, and has it already come back?** It's built for a return-desk scanner or POS plugin — one call, one decision. Don't confuse this with [Serial Verification](/guides/verification-service) (`gs1:verificationService`). That's a public, unauthenticated anti-counterfeit check anyone can run from a consumer scan. This is an authenticated, retailer-scoped returns check — a different question, a different API, a different audience. ## How a unit becomes verifiable Verification reads from your organization's own [EPCIS 2.0](/guides/epcis-events) event history — there's no separate "sale record" table, and no state to keep in sync by hand. Get the events in, and the verify call reads them back out. ### Recording a sale Two ways to get sale events in, both landing on the same event log: **Real-time, per serial** — capture it like any other EPCIS event: ```bash theme={null} curl -X POST https://www.closient.com/epcis/api/2.0/capture \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "ObjectEvent", "action": "OBSERVE", "eventTime": "2026-07-15T14:32:00Z", "eventTimeZoneOffset": "+00:00", "epcList": ["https://id.gs1.org/01/00614524740108/21/ABC123XYZ"], "bizStep": "retail_selling", "disposition": "retail_sold", "bizTransactionList": [{"type": "po", "bizTransactionID": "TXN-88213"}] }' ``` **Nightly batch** — upload your POS export as a CSV instead of one call per sale: ``` POST /epcis/api/v1/sales-imports/csv GET /epcis/api/v1/sales-imports/template # sample CSV with the expected columns ``` Each row is translated into the same `retail_selling` / `retail_sold` event server-side. Re-uploading a file is safe — rows already captured are reported as `skipped`, not duplicated, so a replayed nightly export is a no-op. Maximum upload size 10 MiB, maximum 10,000 rows per upload. ### Recording a return Closing the loop uses the same capture endpoint with a different business step: ```json theme={null} { "bizStep": "returning", "disposition": "returned", "epcList": ["https://id.gs1.org/01/00614524740108/21/ABC123XYZ"] } ``` Once that lands, the next verify call on the same serial reports `already_returned` — which is what stops the same unit being refunded twice. ## The verify call ``` GET /epcis/api/2.0/verification/serial?gtin={gtin}&serial={serial} ``` | Parameter | Format | | - | - | | `gtin` | GTIN-8/12/13/14. Normalized to GTIN-14; an invalid check digit returns `422`. | | `serial` | GS1 AI(21) serial number, 1–255 characters. | Requires an `X-API-Key` for an organization membership with **OWNER or MANAGER** role — this is a privileged, retailer-side call, not a public scan. ```json theme={null} { "decision": "already_returned", "gtin": "00614524740108", "serial": "ABC123XYZ", "lot": "L2847", "sold_at": "2026-07-15T14:32:00Z", "sale_transaction_reference": "TXN-88213", "returned_at": "2026-07-22T09:10:00Z", "recall": null } ``` ### Decision states | `decision` | Meaning | | - | - | | `sold` | This organization sold this exact unit and it hasn't come back. | | `already_returned` | This organization already accepted a return on this unit — a second return attempt is the double-return pattern this exists to catch. | | `never_sold` | This organization has events for this serial (received it, shipped it) but never recorded selling it. A return attempt with `never_sold` didn't come from this store's shelf. | | `unknown_serial` | This organization has no event history for this GTIN + serial at all. | | `recalled_lot` | The unit belongs to a lot under an open recall. This outranks every state above it — even a legitimately sold-and-returnable unit reports `recalled_lot` if its lot is recalled, and the sale/return fields stay populated alongside it. | `recall` is populated only when `decision` is `recalled_lot`, and carries `recall_id`, `title`, `severity`, and the matched `lot`. ## Why the states catch what they catch Serialization at the return desk is a targeted defense, not a blanket one — it's worth being specific about what each pattern trips: * **Receipt fraud** (a valid receipt paired with a different, unpurchased unit) — the receipt matches a transaction, but the physical serial being returned was never the one sold under it, so it reports `never_sold` regardless of how convincing the paperwork looks. * **Double returns** — the second attempt on an already-processed unit reports `already_returned` instead of quietly refunding twice. * **Cross-retailer arbitrage** (bought at one retailer, returned at another for a better refund) — because verification only ever reads *your* organization's own events, a unit this store never sold reports `never_sold` or `unknown_serial` here even though another retailer's system would show it as legitimately sold. * **Counterfeit-swap returns** (genuine unit purchased, a counterfeit substituted back into the box before return) — this only works if the serial is scanned off the *unit itself* at the return desk, not read off the outer packaging. A code that lives only on the box comes back unchanged no matter what's inside it. ## Tenant isolation Verification answers are computed **only** from the querying organization's own events. Two retailers selling the same GTIN + serial (unlikely, but the model doesn't assume otherwise) hold entirely separate event histories and neither can see the other's. The one deliberate exception is recall status: a recall is public product-safety information, not one retailer's private event, so an open recall on a unit is reported regardless of which organization published it or which organization is asking. ## Performance The read is a single indexed lookup against denormalized columns cached on the serial's row — never a walk through the event log. This is what makes it safe to call inline at a return desk rather than as a background check. ## Errors | Status | When | | - | - | | `401` | Missing or invalid API key. | | `403` | Authenticated, but not an OWNER/MANAGER on the organization. | | `422` | GTIN fails GS1 check-digit validation. | | `429` | Rate limit exceeded (default 300/min, 10,000/day per key). | ## Related * [EPCIS 2.0 Events](/guides/epcis-events) — the event log this reads from * [Serial Verification](/guides/verification-service) — the *different*, unauthenticated anti-counterfeit check; don't confuse the two # Serial Verification (gs1:verificationService) Source: https://docs.closient.com/guides/verification-service Verify serialized GTIN scans against the Closient resolver — tiered, rate-limited, enumeration-safe. **Experimental (v1):** This linkType response shape is published as a v1 experimental contract. The auth-tier split, response fields, and `verificationStatus` enum may change before the first external integration freezes the shape. Closient will announce a stable v1.0 once an external scanner or 2D-cert tool integrates against the response. The `gs1:verificationService` linkType returns the verification status of a serialized trade item — a **GTIN + AI 21 serial** pair scanned against the Closient resolver. Two tiers are exposed: | Tier | Caller | Auth | Rate limit | Payload | | - | - | - | - | - | | **Minimal** | Consumer scan (browser, native scanner) | none | 60 / hour / IP | 5 fields, no scan history | | **Full** | Brand dashboard, retailer POS, 2D-cert tool | `X-API-Key` | per-key (default 300/min) | scan history, anomaly entries, prior-event hints | ## Endpoint ``` GET https://www.closient.com/resolver/api/v1/verify/01/{gtin}/21/{serial} ``` Path parameters mirror the GS1 Digital Link AI ordering used elsewhere in the resolver (`01` = GTIN, `21` = serial). | Parameter | Format | Notes | | - | - | - | | `gtin` | 8–14 digit GTIN | GTIN-8/12/13/14; non-digit input returns `422 invalid_gtin`. | | `serial` | 1–20 printable ASCII characters | AI 21. Out-of-range characters return `422 invalid_serial`. | ## Authentication Authenticated calls supply an organization API key in the `X-API-Key` header. The same key shape used elsewhere in the Closient API is accepted (`csb_*`). ```http theme={null} GET /resolver/api/v1/verify/01/09521101530018/21/SN001 HTTP/1.1 Host: www.closient.com X-API-Key: csb__ ``` Calls without `X-API-Key` (or with an unknown key) fall through to the minimal-tier response and are subject to the IP rate limit. ## Unauthenticated response (consumer) ```json theme={null} { "verificationStatus": "authentic", "gtin": "09521101530018", "serialNumber": "SN001", "verifiedAt": "2026-05-09T20:00:00Z", "recommendation": "proceed" } ``` | Field | Type | Description | | - | - | - | | `verificationStatus` | enum | `authentic`, `suspect`, `counterfeit_suspected`, `serialization_error` | | `gtin` | string | Echoed GTIN from the request path | | `serialNumber` | string | Echoed AI 21 serial from the request path | | `verifiedAt` | ISO 8601 timestamp | When this response was generated | | `recommendation` | enum | `proceed` for authentic / unverified / unknown serials, `flag_for_review` otherwise | ### Enumeration protection Serials we have never seen return the **same shape** as known-authentic serials (`verificationStatus: "authentic"`, `recommendation: "proceed"`). Consumer-facing scanners cannot use this endpoint to probe whether a particular serial is in the Closient database. Brands needing the truthful "we have not seen this serial" signal must authenticate — the auth-tier response returns `scanHistory: null` for never-seen serials. ### Rate limit Unauthenticated calls are limited to **60 requests per hour per client IP**. The 61st call returns `429 Too Many Requests` with a `Retry-After` header indicating the seconds remaining in the bucket. ## Authenticated response (brand / retailer) ```json theme={null} { "verificationStatus": "suspect", "gtin": "09521101530018", "serialNumber": "DUPE001", "verifiedAt": "2026-05-09T20:00:00Z", "scanHistory": { "totalScans": 5, "retailerScans": 2, "consumerScans": 3, "distinctRetailLocations": 2, "firstSeen": "2026-04-01T10:00:00Z" }, "anomalies": [ { "type": "duplicate_retail_scan", "description": "Serial previously scanned at POS at a different retail location.", "priorEvent": { "location": "49.2827,-123.1207", "retailer": "RetailerA", "scannedAt": "2026-04-10T14:30:00Z" } } ], "recommendation": "flag_for_review" } ``` The auth-tier response is a strict superset of the unauth-tier response — every consumer field is preserved verbatim. ### `scanHistory` | Field | Type | Description | | - | - | - | | `totalScans` | int ≥ 0 | All scan-context counters summed | | `retailerScans` | int ≥ 0 | Authenticated retailer-system scans | | `consumerScans` | int ≥ 0 | Browser / unauthenticated scans | | `distinctRetailLocations` | int ≥ 0 | Unique POS locations that have scanned this serial | | `firstSeen` | ISO 8601 timestamp | Earliest observed scan | `scanHistory` is **`null`** when the serial has never been observed by the resolver (auth tier only — the unauth tier omits the field entirely). ### `anomalies` Empty array when no rule has fired. Phase 1 emits a single rule: | `type` | Trigger | `priorEvent` | | - | - | - | | `duplicate_retail_scan` | `retailerScans ≥ 2 AND distinctRetailLocations ≥ 2` | Most recent retailer scan from a different POS | Each anomaly entry carries: | Field | Type | Description | | - | - | - | | `type` | string | Stable rule identifier | | `description` | string | Human-readable explanation | | `priorEvent.location` | string | `lat,lon` coordinates (4 decimal places), `GLN:` when only a GLN is captured, or empty string | | `priorEvent.retailer` | string | Retailer organization name when the prior scan was authenticated, empty otherwise | | `priorEvent.scannedAt` | ISO 8601 timestamp | Prior event time (UTC) | Phase 3 (tracked in C-2292) layers on the remaining rules — excessive scans, geographic impossibility, post-sale re-entry, serialization errors, mass-scan — each with its own `type` value. ## Status values | Value | Meaning | Default `recommendation` | | - | - | - | | `authentic` | No negative signals; treat as genuine | `proceed` | | `suspect` | At least one anomaly rule has fired | `flag_for_review` | | `counterfeit_suspected` | Operator-confirmed counterfeit (Phase 3+) | `flag_for_review` | | `serialization_error` | Brand serialization scheme violated (Phase 3+) | `flag_for_review` | The `unverified` internal state is **never returned to consumers** — it collapses to `authentic` for the public contract until Phase 3 ships positive-authentication rules. ## Errors | Status | Body | When | | - | - | - | | 200 | Verification payload | Always for valid path parameters | | 422 | `{"error": "invalid_gtin", "detail": "..."}` | GTIN is not 8–14 digits | | 422 | `{"error": "invalid_serial", "detail": "..."}` | Serial not 1–20 printable ASCII characters | | 429 | Rate-limit envelope, `Retry-After` header | Anonymous IP exceeded 60/hour | ## Stability guarantees This linkType is **experimental v1**. Until at least one external scanner / 2D-cert tool pins to the contract, the following may change with notice in the changelog: * Field names (`verificationStatus`, `recommendation`, etc.) * The `verificationStatus` enum set * The auth-tier split (which fields are unauth vs auth) * The `recommendation` mapping table * The shape of `anomalies` entries Stable in all v1 revisions: * The path layout (`/resolver/api/v1/verify/01//21/`) * The `X-API-Key` auth scheme * The 60/hour anonymous rate limit * The enumeration-protection guarantee (unknown serial = known-authentic shape) When the contract freezes, Closient will announce v1.0 in the API changelog. ## Related linkTypes * [GS1 Digital Link Resolution](/guides/digital-link-resolution) — generic resolver behaviour * [EPCIS Events](/guides/epcis-events) — the long-form event log this verification view summarizes # Webhooks Source: https://docs.closient.com/guides/webhooks Receive real-time HTTP callbacks when events occur in your Closient account. Webhooks deliver real-time notifications to your server when events occur in Closient. Each webhook endpoint receives signed HTTP POST requests with JSON payloads. ## Creating a Webhook Endpoint Configure endpoints in **Settings > Integrations** in your [Dashboard](https://www.closient.com/dashboard/), or via the [Integrations API](/api-reference/integrations). Each endpoint has: * **URL** — your HTTPS endpoint (HTTPS required in production) * **Event types** — which events to subscribe to, from the [event catalog](#event-catalog). Prefix matching is supported: subscribing to `"recall"` receives every `recall.*` event, **including ones added later** — the recommended form for safety-critical domains. Values outside the catalog are rejected with a `422`, so a typo can never leave you silently subscribed to nothing. * **API version** — the [envelope version](#envelope-versioning) this endpoint receives. Pinned at creation; we never move it. * **Filters** — optional structured filters, scoped to the recall payload shape (see [Filters](#filters) below) * **Signing secret** — auto-generated `whsec_`-prefixed secret for payload verification. Shown once; store it somewhere you can read it back. Endpoints created before this format shipped keep their existing secret, which stays valid. A newly created endpoint starts in `state: pending_verification` and receives nothing. It becomes `active` once it answers the [verification handshake](#endpoint-verification) — usually within the same request, since we send it immediately. ## Destination requirements A webhook URL is a URL we will make requests to on your behalf, so what we accept is deliberately narrow. Every rule below is applied when you register the endpoint **and again immediately before every single delivery**. | Rule | Detail | | - | - | | **HTTPS only** | `http://` is rejected in every environment, including local development. There is no setting that relaxes it. | | **No credentials in the URL** | `https://user:pass@host/path` is rejected. Authenticate deliveries by [verifying the signature](#verifying-signatures-cloudevents-10), not by a secret in the URL. | | **Publicly routable host** | The hostname must resolve exclusively to globally routable addresses. Private (RFC 1918), loopback, link-local, carrier-NAT, multicast, reserved, documentation and cloud-metadata ranges are rejected, for both IPv4 and IPv6 — including addresses reached through IPv4-mapped, 6to4 or Teredo forms. If a hostname resolves to a mix of public and private addresses, the whole hostname is rejected. | | **Any port** | `443` is the default; a non-standard port such as `8443` is fine. | | **No redirects** | A `3xx` response to a delivery is **rejected, not followed** — register the final URL. The delivery is recorded as failed with the rejected status code and takes the ordinary [retry ladder](#retry-policy). | | **Bounded response** | We read at most 8 KiB of your response body and discard the rest. Return a short body. | A rejected URL comes back as `422` with the specific reason: ```json theme={null} { "type": "https://docs.closient.com/errors/validation_error", "title": "Validation Error", "status": 422, "detail": "hooks.example.com resolved to 10.0.0.7, which is in 10.0.0.0/8 — private (RFC 1918).", "details": { "field_errors": { "url": ["hooks.example.com resolved to 10.0.0.7, which is in 10.0.0.0/8 — private (RFC 1918)."] } } } ``` **Re-checking at delivery time is deliberate.** A hostname that resolved publicly at registration and privately at delivery time (DNS rebinding) is caught on the delivery, and the connection is opened against the address that passed the check rather than a freshly resolved one. When that happens no request is made at all: the delivery is dead-lettered immediately rather than retried, and the reason is on the delivery record. An endpoint whose last three deliveries were refused this way is disabled and you are emailed, whatever it delivered successfully earlier in the day. If you see this, your DNS moved, not ours. A destination that simply **cannot be resolved** — a deleted record, a SERVFAIL, a resolver hiccup — is not that. It takes the ordinary [retry ladder](#retry-policy), because a transient DNS failure is a transient failure. ### Per-organization limits | Limit | Default | | - | - | | Webhook endpoints per organization | 5 | | Distinct destination hosts per organization | 5 | Paused and unverified endpoints count against both; deleted ones do not. Several endpoints on one host count as one host. Neither limit depends on your plan — they bound our outbound request volume, not your access to safety events. If you have a legitimate need for more, [ask us](mailto:support@closient.com). ## Endpoint verification Before the first real delivery, your destination has to confirm that it wants our traffic. Closient uses the abuse-protection handshake from the [CloudEvents HTTP webhook spec](https://github.com/cloudevents/spec/blob/main/cloudevents/http-webhook.md) rather than a bespoke challenge, so an off-the-shelf CloudEvents receiver already implements it. We send an `OPTIONS` request to your endpoint URL: ```http theme={null} OPTIONS /webhooks/closient HTTP/1.1 Host: hooks.example.com WebHook-Request-Origin: www.closient.com WebHook-Request-Rate: 120 User-Agent: Closient-Webhooks/1.0 ``` Answer `200` with the origin echoed back: ```http theme={null} HTTP/1.1 200 OK WebHook-Allowed-Origin: www.closient.com WebHook-Allowed-Rate: 120 ``` * `WebHook-Allowed-Origin` — **required.** Either the exact value of `WebHook-Request-Origin`, or `*`. A comma-separated list is accepted. * `WebHook-Allowed-Rate` — optional, requests per minute you are willing to accept, or `*`. We record it; we do not yet pace deliveries against it. A minimal handler: ```python Python (Django) theme={null} def closient_webhook(request): if request.method == "OPTIONS": response = HttpResponse(status=200) response["WebHook-Allowed-Origin"] = request.headers.get("WebHook-Request-Origin", "*") response["WebHook-Allowed-Rate"] = "120" return response ... # verify the signature, then handle the event ``` ```javascript Node.js (Express) theme={null} app.options("/webhooks/closient", (req, res) => { res.set("WebHook-Allowed-Origin", req.get("WebHook-Request-Origin") || "*"); res.set("WebHook-Allowed-Rate", "120"); res.sendStatus(200); }); ``` Retry the handshake at any time: ```bash theme={null} POST /integrations/api/v1/webhooks/endpoints/{endpoint_id}/verify/ ``` It returns the endpoint on success, or `422` with the reason on failure. The same reason is readable on the endpoint any time as `verification_error`, alongside `verified_at` and the current `state`. **Changing an endpoint's `url` revokes its verification.** The endpoint returns to `pending_verification`, stops receiving deliveries, and the handshake is re-run against the new destination. Consent belongs to a destination, not to a subscription — so plan a URL change as a short gap, and read the [replay feed](#replay-on-reconnect) from your last cursor once the new URL is verified. ## Filters An endpoint can narrow which events it receives on five dimensions, all readable off the recall payload (`data.*` in the envelope): | Dimension | Matches against | Example | | - | - | - | | `gtins` | `data.gtins` — an exact GTIN-14 list | `["00012345600012"]` | | `company_prefixes` | The GS1 Company Prefix embedded in each of `data.gtins` | `["0012345"]` | | `jurisdictions` | `data.jurisdictions` | `["US", "CA"]` | | `gpc_codes` | `data.gpc_brick_codes` | `["10000123"]` | | `lifecycle_statuses` | `data.lifecycle_status` | `["PUBLISHED"]` | **AND across dimensions, OR within a dimension.** An endpoint with `jurisdictions: ["US"]` and `lifecycle_statuses: ["PUBLISHED"]` only receives an event that is both US-jurisdiction **and** published — but `jurisdictions: ["US", "CA"]` alone receives an event from either. **An empty (or unset) dimension always means "no restriction on this dimension."** An endpoint created with no filters at all receives every event its event-type subscription allows — filters only ever narrow, never require opting in. **`gtins` is answered by an indexed lookup, not a list scan** — an endpoint can subscribe to hundreds of thousands of individual GTINs (built for retailers tracking a full catalog) with no per-event cost difference from subscribing to ten. `company_prefixes` reads the prefix out of a normalized GTIN-14 string. For a GTIN whose *native* length is 13 or 14 digits this is exact. For a GTIN sourced from UPC-A (GTIN-12, zero-padded to 14 under GS1's convention), the prefix window is shifted by one digit, so a `company_prefixes` filter can under-match a UPC-A-sourced GTIN. If your integration deals primarily in UPC-A products, prefer the `gtins` dimension (exact match) over `company_prefixes` for that catalog. **Changing a filter is never retroactive.** It takes effect for events emitted after the change — it does not replay, re-deliver, or backfill anything that was already excluded (or included) under the old filter, live or in the [replay feed](#replay-on-reconnect). If you need the history a filter previously excluded, read the event feed from a cursor that predates the filter change: `GET /integrations/api/v1/webhooks/events/?after=` returns every matching event regardless of any endpoint's current filters, since replay is scoped by event type, not by subscription filters. ## Event Catalog Fetch the live catalog — this is the authoritative list, and it is also published in the [OpenAPI spec](/api-reference/integrations): ```bash theme={null} GET /integrations/api/v1/webhooks/event-types/ ``` ### Recalls & safety | Event | Fires when | | - | - | | `recall.opened` | A recall affecting one of your products was opened — ingested from a regulator (FDA, FSIS, CPSC, Health Canada) or issued by a brand from the dashboard. | | `recall.amended` | An existing recall's details changed at the source (title, hazard, remedy, severity, lot codes, distribution) **without** its product scope growing. Update your copy; don't re-alarm. | | `recall.expanded` | An existing recall grew to cover **additional** products. Treat with the same urgency as `recall.opened` — a product you previously treated as clear now isn't. | | `recall.closed` | A recall reached a terminal state (completed, terminated, or withdrawn by the issuing brand). Stop showing the warning. | ### Consumer signals | Event | Fires when | | - | - | | `signal.received` | A consumer submitted a signal on one of your claimed products. Fires for every sentiment, including positive and undeclared. | | `adverse_report.received` | A consumer filed an adverse report, with lot/serial context, the consumer-declared state/province (`consumer_region`) and onset date/time when given, and the SLA delivery state. | | `adverse_report.threshold_alert` | Adverse reports against a single lot crossed the alert threshold — the early warning before a recall decision. `distinct_region_count` says how many different states/provinces those reports came from. | ### Serial verification | Event | Fires when | | - | - | | `serial_verification.flagged` | Serials on your products were flagged as non-authentic (cloned, over-scanned, or out-of-region). | ### Scans & resolves | Event | Fires when | | - | - | | `scan.recorded` | One of your products was scanned or resolved — a GS1 Digital Link request that matched a GTIN you own. | `scan.recorded` carries the scan's identity and the qualifiers that were on the wire: `gtin`, `scanned_lot`, `scanned_serial`, `scan_type`, `channel`, the resolved `destination_url`, and the ESL context (`retailer_id`, `store_id`, `scanned_sku`) when the scan came from a shelf label rather than a pack. `scanned_lot` and `scanned_serial` are the **raw scanned values**, including ones that match no lot or serial we have registered — which is exactly the case worth watching for. **Every qualifying scan fires — there is no sampling.** Bot and crawler traffic is excluded (so these counts agree with the scan analytics in your dashboard), and so are scans of products no organization has claimed. Geographic and device fields are deliberately **not** in the payload: they are enriched asynchronously after the scan row is written, so including them would mean publishing an empty value that is indistinguishable from a real "unknown". ### Traceability | Event | Fires when | | - | - | | `epcis.event_captured` | An EPCIS event was captured into your traceability store. | The payload is an identity plus a pointer, never the document: `event_id`, `epcis_event_type` (`ObjectEvent`, `AggregationEvent`, `TransformationEvent`, …), `action`, `biz_step`, `disposition`, `read_point`, `biz_location`, `epc_count`, and `url` — the EPCIS 2.0 query URL you `GET` for the full event under your own credentials. When the capture also materialised an FSMA 204 Critical Tracking Event, `cte` names its type, date and lot; otherwise `cte` is `null` (present, so you can branch on it without a key check). ### Catalog | Event | Fires when | | - | - | | `product.claim_status_changed` | A GTIN's claim-verification status moved: `unverified`, `prefix_matched`, `verified`, `disputed`, `rejected`, or released. | `previous_status` and `new_status` are both always present and either may be `null` — as a previous status it means the GTIN had never entered the claim flow; as a new status it means the claim was released and the GTIN is unclaimed again. `trigger` names the cause, and `automated` is `true` when our scheduled re-verification demoted the claim (prefix drift or evidence expiry) rather than a person deciding — that distinction is the one worth routing on, because an automated demotion is a "re-verify" prompt rather than an adjudicated outcome. A rejection or release is delivered to the organization that **held** the claim, not to whoever holds it afterwards. That is the one transition you would otherwise learn about only by polling. ### System | Event | Fires when | | - | - | | `test.ping` | Delivered by the [test endpoint](#testing). Never emitted by real platform activity. | ### Deprecated `offer.updated`, `product.recalled`, and `retailer.created` predate the catalog. They remain valid in a subscription so existing integrations keep working, but no emitter is wired for them. `product.recalled` is superseded by the `recall.*` lifecycle above — build new integrations against that. The catalog response also has a `planned` section listing event types on our roadmap. Those are **not** subscribable yet: subscribing returns a `422` naming the tracking issue, rather than accepting a subscription that would silently never fire. ## Envelope Versioning Every endpoint carries an `api_version` that pins the shape of the envelope it receives. **We never move an endpoint's pin.** Shipping a new envelope version does not change what your existing integration receives — change `api_version` yourself, with a `PATCH`, once your parser is ready. | Version | Notes | | - | - | | `cloudevents-1.0` | Current. New endpoints are created on this version. A spec-conformant [CloudEvents 1.0](https://cloudevents.io/) envelope, signed with [Standard Webhooks](https://www.standardwebhooks.com/). | | `2026-07-01` | CloudEvents-*shaped* field names, but not the specification, and signed with the legacy `X-Closient-Signature` scheme. | | `v1` | The pre-catalog envelope. Endpoints created before the catalog shipped are pinned here permanently. | The envelope pin also selects the **signing scheme** — `cloudevents-1.0` endpoints receive `webhook-signature` headers, the other two receive `X-Closient-Signature`. One knob, so an endpoint is never half-migrated. Changing `api_version` changes both at once; update your handler first. ### `cloudevents-1.0` A structured-mode CloudEvents 1.0 event. Any CloudEvents SDK, Knative Eventing trigger, Azure Event Grid subscription or iPaaS connector parses it without a Closient-specific adapter — that is the point of adopting the standard. ```json theme={null} { "specversion": "1.0", "type": "recall.opened", "source": "https://www.closient.com/recalls", "id": "evt_a1b2c3d4e5f6", "time": "2026-09-17T12:00:00+00:00", "datacontenttype": "application/json", "recallgdti": "0123456789012", "recordversion": 3, "jurisdiction": "US,CA", "cursor": "2026-09-17T12:00:00+00:00~4t3rmMvzGQrqAqZ3vPPGmM", "data": { "recall_id": "4t3rmMvzGQrqAqZ3vPPGmM", "gdti": "0123456789012", "record_version": 3, "canonical_url": "https://www.closient.com/pip/00012345678905/recall/4t3rmMvzGQrqAqZ3vPPGmM", "gtins": ["00012345678905"], "severity": "CLASS_I", "status": "ONGOING" } } ``` | Attribute | Type | Description | | - | - | - | | `specversion` | string | Always `1.0` — the **CloudEvents** version. Your Closient envelope pin is `cloudevents-1.0`; the two are deliberately different strings. | | `type` | string | Event type from the catalog. | | `source` | string | The producing feed: `…/recalls` for `recall.*` events, `…/webhooks` otherwise. An identifier, not an endpoint to fetch. | | `id` | string | Unique event identifier (`evt_` prefix), stable across retries. Together with `source` this is your deduplication key. | | `time` | string | RFC 3339 timestamp of when the event **occurred**, not when it was delivered. | | `datacontenttype` | string | Always `application/json`. | | `data` | object | Event-specific payload. Structure varies by event type. | #### Routing extension attributes The fields you are most likely to route on are lifted into CloudEvents **extension attributes**, so a Knative `Trigger` filter or an Event Grid advanced filter can match without parsing `data`: | Attribute | Type | Description | | - | - | - | | `recallgdti` | string | The recall's GDTI (AI 253) — its permanent public identity, stable across amendments. **Omitted** when a GDTI has not been minted yet; the spec forbids an empty attribute. | | `recordversion` | integer | Monotonic version of the canonical record. Increments only when a subscriber-meaningful field changes. | | `jurisdiction` | string | ISO 3166-1 alpha-2 codes. **Comma-joined** for a multi-jurisdiction recall (`"US,CA"`) — CloudEvents has no array type, so match with "contains", not equality. | | `cursor` | string | Opaque resume token — see [replay](#replay-on-reconnect). | Extension attribute names are lowercase letters and digits only, per the CloudEvents naming rules — which is why it is `recordversion` here and `record_version` inside `data`. ### `2026-07-01` CloudEvents-shaped field names, plus a `cursor` for [replay](#replay-on-reconnect): ```json theme={null} { "specversion": "2026-07-01", "type": "recall.opened", "id": "evt_a1b2c3d4e5f6", "time": "2026-07-29T12:00:00+00:00", "source": "closient", "cursor": "2026-07-29T12:00:00+00:00~4t3rmMvzGQrqAqZ3vPPGmM", "data": { "recall_id": "4t3rmMvzGQrqAqZ3vPPGmM", "gtins": ["00012345678905"], "severity": "CLASS_I", "status": "ONGOING" } } ``` | Field | Type | Description | | - | - | - | | `id` | string | Unique event identifier (`evt_` prefix). Use for idempotency. | | `type` | string | Event type from the catalog. | | `specversion` | string | Envelope version. Matches your endpoint's `api_version`. | | `time` | string | ISO 8601 timestamp of when the event **occurred**. | | `source` | string | Always `closient`. | | `cursor` | string | Opaque resume token — see [replay](#replay-on-reconnect). Treat as opaque. | | `data` | object | Event-specific payload. Structure varies by event type. | ### `v1` ```json theme={null} { "event_type": "recall.opened", "version": "v1", "event_id": "evt_a1b2c3d4e5f6", "timestamp": "2026-07-29T12:00:00+00:00", "data": { "recall_id": "4t3rmMvzGQrqAqZ3vPPGmM" } } ``` Same information, different field names, and no `cursor` (v1 predates the replay feed). The `data` block is **identical** across versions — versioning governs the envelope only, never the event payload. ## Replay on Reconnect Retries cover an endpoint that is failing. They do not cover an endpoint that was simply **down**, was auto-disabled after sustained failures, or did not exist yet. For a recall feed that gap matters more than anything else in this document: a subscriber who missed a recall and can't tell they missed it is worse off than one with no feed at all. So every event is written to a durable log when it is emitted — before any delivery is attempted — and you can walk that log forward from wherever you left off: ```bash theme={null} GET /integrations/api/v1/webhooks/events/?after=&recalls_only=true ``` The loop: 1. Persist the `cursor` of the last event you **durably processed** — not the last one you received. 2. On reconnect, call the endpoint with `?after=`. 3. Process the page, then repeat using the last cursor on the page, until a page comes back short. Events are returned **oldest first**. Each carries an `envelope` that is byte-identical to what a live delivery would have POSTed, so a replayed event goes through the same handler with no special casing, and `event_id` is stable across the live delivery and every replay — deduplicate on it. | Parameter | Description | | - | - | | `after` | Cursor to resume strictly after. Omit to start from the oldest retained event. | | `event_type` | Restrict to these types. Repeat for multiple values. | | `recalls_only` | Shorthand for the whole recall domain. Prefer this over listing the four `recall.*` values — it keeps covering you when a fifth ships. | | `api_version` | Render each `envelope` at this version. Defaults to the version your endpoints are pinned to. | | `limit` | Page size (max 200). | The cursor is a keyset over `(occurred_at, id)`, not a timestamp. That is what makes the walk both gap-free and duplicate-free when two events share a timestamp — a plain `since=` filter can only pick one of those two failure modes. An unrecognised cursor returns `422`. We deliberately do not fall back to "start from the beginning" (which would flood you) or "start from now" (which would hide the very gap you reconnected to close). ## Request Headers Every delivery includes these headers: | Header | Example | Description | | - | - | - | | `X-Closient-Event-Type` | `recall.opened` | The event type (same as `type` / `event_type` in the body). | | `X-Closient-Delivery-ID` | `d290f1ee-6c54-...` | Unique delivery attempt ID. | | `User-Agent` | `Closient-Webhooks/1.0` | Identifies Closient as the sender. Fixed and stable — see below. | ### Identifying our requests Every request we make to your endpoint — deliveries, test pings and replays — carries exactly: ``` User-Agent: Closient-Webhooks/1.0 ``` It is a published part of the contract: it does not vary by environment, event type or endpoint, and the version segment moves only with a documented change to the delivery mechanism. **Signature verification is the control that matters, not the source address.** Verify `webhook-signature` (or `X-Closient-Signature` on a legacy pin) on every request and reject anything that fails; a request is genuine because it is signed with your endpoint's secret, not because of where it came from. The `User-Agent` is for logging, routing and metrics — treat it as a hint, since anyone can send that string. Closient does not currently publish a fixed egress IP range for allowlisting. If your receiver is behind a firewall that requires one, [contact us](mailto:support@closient.com) with your requirements rather than allowlisting an address you observed — deployment moves, and an observed address is not a commitment. The signature headers and `Content-Type` depend on your endpoint's [envelope pin](#envelope-versioning): | Header | `cloudevents-1.0` | `2026-07-01` / `v1` | | - | - | - | | `Content-Type` | `application/cloudevents+json; charset=UTF-8` | `application/json` | | `webhook-id` | `evt_a1b2c3d4e5f6` | — | | `webhook-timestamp` | `1711972800` | — | | `webhook-signature` | `v1,K5oZfzN95Z9UVu1EsfQm...` | — | | `X-Closient-Signature` | — | `t=1711972800,v1=5a3c...` | ## Verifying Signatures (`cloudevents-1.0`) Endpoints on the current envelope are signed with **[Standard Webhooks](https://www.standardwebhooks.com/)**. The scheme is published and widely implemented, so the shortest correct integration is to not write one: ```python Python theme={null} # pip install standardwebhooks from standardwebhooks import Webhook wh = Webhook(endpoint_signing_secret) # the whsec_... secret from your endpoint event = wh.verify(raw_request_body, dict(request.headers)) ``` ```javascript Node.js theme={null} // npm install standardwebhooks import { Webhook } from "standardwebhooks"; const wh = new Webhook(endpointSigningSecret); const event = wh.verify(rawRequestBody, headers); ``` The `svix` libraries implement the same scheme and work identically. ### The scheme, if you'd rather implement it 1. Read `webhook-id`, `webhook-timestamp` and `webhook-signature`. 2. Reject if `webhook-timestamp` is more than **300 seconds** away from now, **in either direction**. A timestamp far in the future is as suspicious as a stale one, and a one-sided check rejects honest clock skew. This is your replay protection — nothing else bounds how long a captured delivery stays valid. 3. Derive the key: strip the `whsec_` prefix and **base64-decode the rest**. The key is those bytes, not the string. 4. Compute `base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw_body}"))`. Sign the **raw bytes you received** — re-serialising the parsed JSON changes key order and separators, and therefore the signature. 5. `webhook-signature` holds one or more space-delimited `v1,` tokens. Accept if **any** `v1` token matches, using a constant-time comparison. Skip tokens with an unrecognised version prefix rather than rejecting the whole header. ```python theme={null} import base64, hashlib, hmac, time def verify_closient_webhook(body: str, headers, secret: str, tolerance: int = 300) -> bool: """Verify a Closient `cloudevents-1.0` delivery. `body` is the raw request body.""" h = {k.lower(): v for k, v in headers.items()} msg_id, timestamp, signature = h.get("webhook-id"), h.get("webhook-timestamp"), h.get("webhook-signature") if not (msg_id and timestamp and signature): return False if abs(int(time.time()) - int(timestamp)) > tolerance: return False # stale or implausibly future — replay protection key = base64.b64decode(secret.removeprefix("whsec_") + "==") expected = base64.b64encode( hmac.new(key, f"{msg_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest() ).decode() return any( hmac.compare_digest(expected, token.split(",", 1)[1]) for token in signature.split(" ") if token.startswith("v1,") ) ``` `webhook-id` equals the envelope's `id` and is **stable across every retry and every endpoint** the event fans out to, so it doubles as your idempotency key. `webhook-timestamp` is *not* stable — it authenticates each transmission, which is what makes the replay window meaningful. ### Rotating a signing secret with no gap `POST /endpoints/{id}/rotate-secret` starts a **24-hour** window during which every delivery carries a signature under the new secret **and** one under the outgoing secret, space-delimited in the same header: ``` webhook-signature: v1, v1, ``` Because the spec says to accept on *any* matching signature, there is no instant at which a genuine delivery fails verification: before you swap, the old signature matches; after you swap, the new one does. The published libraries handle multiple signatures for you. If you implement verification yourself, make sure you check **every** `v1` token rather than only the first. ## Verifying Signatures (`2026-07-01` and `v1`) Endpoints on a legacy envelope pin keep the Stripe-style `X-Closient-Signature` header, unchanged: ``` t=1711972800,v1=5a3c9f... ``` To verify: 1. Extract the timestamp (`t`) and signature (`v1`) from the header. 2. Construct the signed payload: `{timestamp}.{raw_json_body}`. 3. Compute HMAC-SHA256 using your signing secret. 4. Compare your computed signature with `v1` using a constant-time comparison. 5. Check the timestamp is within your tolerance window (recommended: 5 minutes). ```python theme={null} import hashlib import hmac import time def verify_closient_webhook( payload: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300, ) -> bool: """Verify a Closient webhook signature. Args: payload: Raw request body bytes. signature_header: Value of X-Closient-Signature header. secret: Your endpoint's signing secret. tolerance_seconds: Max age in seconds (default 5 min). """ # Parse header: t=123,v1=abc... parts = {} for element in signature_header.split(","): key, _, value = element.partition("=") parts[key.strip()] = value.strip() timestamp = parts.get("t", "") if not timestamp: return False # Replay protection age = abs(int(time.time()) - int(timestamp)) if age > tolerance_seconds: return False # Compute expected signature signed_payload = f"{timestamp}.{payload.decode('utf-8')}" expected = hmac.new( secret.encode("utf-8"), signed_payload.encode("utf-8"), hashlib.sha256, ).hexdigest() # Check current signature (v1), then rotated signature (v1old) return ( hmac.compare_digest(expected, parts.get("v1", "")) or hmac.compare_digest(expected, parts.get("v1old", "")) ) ``` ```javascript theme={null} const crypto = require("crypto"); function verifyClosientWebhook(payload, signatureHeader, secret, toleranceSeconds = 300) { // Parse header: t=123,v1=abc... const parts = {}; for (const element of signatureHeader.split(",")) { const [key, ...rest] = element.split("="); parts[key.trim()] = rest.join("=").trim(); } const timestamp = parts.t; if (!timestamp) return false; // Replay protection const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)); if (age > toleranceSeconds) return false; // Compute expected signature const signedPayload = `${timestamp}.${payload}`; const expected = crypto .createHmac("sha256", secret) .update(signedPayload, "utf-8") .digest("hex"); // Check current signature (v1), then rotated signature (v1old) const v1Match = crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(parts.v1 || "", "hex").subarray(0, 32) ); if (v1Match) return true; if (parts.v1old) { return crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(parts.v1old, "hex").subarray(0, 32) ); } return false; } ``` Always verify the signature before processing a webhook payload. Reject requests with missing, expired, or invalid signatures. ### Secret Rotation (legacy envelopes) When you rotate a signing secret via the API (`POST /endpoints/{id}/rotate-secret`), the previous secret remains valid for a **24-hour grace period**. During this window, deliveries include both signatures: ``` t=1711972800,v1=,v1old= ``` Your verification code should check `v1` first, then fall back to `v1old`. After the grace period, only `v1` is included. The same 24-hour window applies on `cloudevents-1.0`, expressed the Standard Webhooks way — see [rotating a signing secret with no gap](#rotating-a-signing-secret-with-no-gap). ## Delivery Guarantees Read these before you write a handler. Each one is a property you have to design for, not an edge case. * **At-least-once.** Every event is delivered at least once to an active endpoint, or [recoverable from the feed](#replay-on-reconnect) if it is not. The same event can arrive more than once: a retry after a response we never saw, a worker restart mid-send, a manual replay, and catch-up from a cursor all resend. **Deduplicate on the CloudEvents `id`** (`event_id` on `v1`), which is stable across every retry, replay and feed read of the same event. * **No ordering guarantee.** Events are sent independently, so a retry of an older event can land after a newer one, and one slow event never holds up the ones behind it. Do not infer order from arrival. * **Recall amendments: newest `record_version` wins.** A recall is identified by its GDTI (`recallgdti`, and `gdti` inside `data`), and `record_version` increases every time the recall changes meaningfully. Keep the highest `record_version` you have applied per GDTI and **discard any event carrying a lower one** — that is how an out-of-order arrival stops overwriting a newer amendment. ## Delivery Lifecycle Each delivery progresses through these statuses: | Status | Description | | - | - | | `pending` | Queued for delivery. | | `delivered` | Your endpoint returned a 2xx response. | | `failed` | Non-2xx response, a rejected redirect, a timeout, or a connection error. Will be retried. | | `dead_letter` | Retries exhausted, the retry window closed, the endpoint was auto-disabled while the delivery was still waiting to retry, or the destination failed [address policy](#destination-requirements) at delivery time (never retried). Can be replayed manually once you have fixed the cause. | ## Retry Policy Failed deliveries are retried with exponential backoff and ±10% jitter: | Attempt | Approximate Delay | | - | - | | 1 | Immediate | | 2 | 30 seconds | | 3 | 2 minutes | | 4 | 15 minutes | | 5 | 1 hour | | 6 | 4 hours | | 7 | 12 hours | | 8 | 24 hours | Retrying is bounded two ways, and whichever runs out first ends it: at most **8 attempts**, and no retry is scheduled more than **48 hours** after the event was first queued. Then the delivery moves to `dead_letter`. You can replay dead-lettered deliveries via the API: ```bash theme={null} POST /integrations/api/v1/webhooks/deliveries/{delivery_id}/replay/ ``` ### Timeouts Each attempt gets **5 seconds to connect** and **15 seconds to respond**. A slower response counts as a failed attempt and takes the retry ladder, so acknowledge first and process afterwards. ### Auto-Disable Closient disables an endpoint automatically, and emails the organization's owners and managers with the reason, when any of these holds: * **the last three deliveries were refused** because the destination failed [address policy](#destination-requirements), regardless of earlier successes, or * **20 consecutive delivery attempts fail** with no success in between (any 2xx resets the count), or * **every delivery in the last 24 hours failed**, across at least three deliveries. The window never reaches back past the endpoint's last state change, so a re-enabled endpoint is judged only on what happened since. A disabled endpoint receives nothing, and the retries still waiting on its ladder are moved to `dead_letter`. The endpoint's `consecutive_failures` field shows how close a live endpoint is to the first rule. It counts **attempts, not events**: every retry counts, as does a delivery refused because the destination failed [address policy](#destination-requirements). A quiet endpoint that is down reaches 20 after only a few events have worked through their retries. **Re-enabling does not resend what you missed.** Re-enable from the Dashboard or with `PATCH /integrations/api/v1/webhooks/endpoints/{endpoint_id}/` and `{"is_active": true}`, which re-runs the [verification handshake](#endpoint-verification). New events flow again from that moment. Nothing from the disabled period is replayed automatically, because a burst of stale events at a receiver that just recovered is the wrong default for a recall feed. To recover the gap, use the endpoint's **`catch_up_cursor`**. It is recorded at the moment of disable, included in the email, and returned on the endpoint until a later auto-disable replaces it. It is the cursor of the last event before the first one this endpoint missed, so reading the feed strictly after it returns everything the endpoint did not get: ```bash theme={null} GET /integrations/api/v1/webhooks/events/?after= ``` URL-encode the cursor, since it contains `+` and `:`. An empty `catch_up_cursor` means the gap starts at the oldest retained event, so read the feed from the start. Catch-up overlaps with events delivered live after you re-enable; [deduplicate on `id`](#delivery-guarantees) and the overlap is harmless. ## Testing Send a test event to verify your endpoint is reachable and signature verification works: ```bash theme={null} POST /integrations/api/v1/webhooks/endpoints/{endpoint_id}/test/ ``` This delivers a `test.ping` event synchronously and returns the HTTP status code from your endpoint. It goes through exactly the same [destination checks](#destination-requirements) as a real delivery — a test ping cannot reach anywhere a delivery could not. ## Best Practices * **Return 2xx quickly** — acknowledge receipt, then process asynchronously. Closient gives each attempt 5 seconds to connect and 15 to respond; see [Timeouts](#timeouts). * **Handle duplicates and reordering** — use the `id` field (`event_id` on `v1`) for idempotency, and keep the highest `record_version` per recall GDTI. See [Delivery Guarantees](#delivery-guarantees). * **Checkpoint your cursor** — persist the `cursor` of the last event you durably processed, so an outage becomes a [replay](#replay-on-reconnect) rather than a silent gap. For recall events this is the difference between a feed you can rely on and one that manufactures false confidence. * **Verify signatures** — always validate the signature before processing, and reject unsigned or expired requests. On `cloudevents-1.0` that means `webhook-signature` plus the 300-second `webhook-timestamp` window; on a legacy pin, `X-Closient-Signature`. * **Use HTTPS, on a publicly reachable host** — enforced everywhere, with no development exemption, and re-checked before every delivery. See [Destination requirements](#destination-requirements). * **Monitor health** — check delivery status in the Dashboard or via the [Integrations API](/api-reference/integrations). Address failures before they accumulate into auto-disable. See the [Integrations API reference](/api-reference/integrations) for full endpoint management, delivery inspection, and filtering options. # Introduction Source: https://docs.closient.com/introduction Resolve GTINs, read product data and capture EPCIS events with the Closient API. Closient is a product data platform that connects brands, retailers, and developers to structured product information through GTINs (Global Trade Item Numbers). The platform combines product catalog management, GS1 Digital Link resolution, supply chain event tracking (EPCIS 2.0), and proximity-based local search into a unified API. ## Who Is This For * **Brands** managing product data, resolver rules, and supply chain visibility * **Retailers** connecting POS systems and managing local inventory * **Developers** integrating product lookup, barcode scanning, or Digital Link resolution ## Core Capabilities GTIN-based product records with images, certifications, dimensions, GPC classification, and data provenance tracking. Products are identified by GTIN-14 (zero-padded from GTIN-8/12/13). Resolve any GTIN to structured product data via GS1-conformant URIs. Supports Application Identifiers for batch/lot (AI 10), serial (AI 21), expiry (AI 17), and more. Configurable resolution rules with time-based routing and scope hierarchy. Capture and query supply chain visibility events per ISO/IEC 19987:2024. Five event types (Object, Aggregation, Transaction, Transformation, Association) with full CBV 2.0 vocabulary support. Hybrid search combining semantic embeddings, lexical matching, and geographic proximity ranking. Results ranked by distance with relevance gating — closest first, not most relevant first. ## API Design All APIs share these conventions: | Convention | Details | | - | - | | Base URL | `https://www.closient.com` | | Authentication | `X-API-Key` header with format `__` | | Rate limits | 300/min, 10,000/day per key (custom limits available) | | Pagination | `?page=1&page_size=25` envelope with `data` + `pagination` | | Errors | Standard `{ "error": { "code", "message", "status", "details" } }` | | IDs | Public-facing IDs use nanoid (12 chars, alphabet: `aceghjkpqrtwxy346789`) | Each API domain (Products, Brands, Locations, etc.) has its own OpenAPI specification and interactive playground in the [API Reference](/api-reference/overview). ## For Users If you're managing products, brands, or integrations through the Dashboard: Add, edit, and organize your product catalog. Establish brand ownership and data authority. Control where your barcodes direct consumers. ## For Developers Sign up at [closient.com/accounts/signup](https://www.closient.com/accounts/signup/). Go to **Settings > API Keys** in your [Dashboard](https://www.closient.com/dashboard/). See the [Quickstart](/quickstart) for a working example. ## For AI Agents If you're integrating an AI assistant (Claude, Cursor, Claude Desktop, VS Code, Windsurf, or a custom MCP client) with Closient, start with [How AI Agents Use Closient](/prompts/mcp-for-ai-agents) — answers to the questions developers actually ask AI assistants about product data and barcode lookup. See also the [Developer Questions](/prompts/index) index for prompt-mapped landing pages. # Metadata Source: https://docs.closient.com/metadata Attach your own key/value data to any Closient API resource. Every Closient API resource accepts a `metadata` field — a flat dict of string keys and string values you can attach to bridge our objects to your own systems (internal IDs, workflow state, audit info, A/B-test buckets, feature flags, anything). Closient never reads `metadata` for behavior. It's opaque developer storage, by design — the same trust contract Stripe makes about its `metadata`. We won't key any feature, billing, search ranking, or default behavior off the contents. ## Shape * **Type:** flat dict, string keys mapped to string values. No nested objects, no arrays, no booleans, no numbers. If you want structured data, serialize it yourself (e.g. JSON-encode and store the string). * **Default:** `{}` — never `null`. The field is always present in responses. ## Limits | Limit | Value | | - | - | | Max keys per object | **50** | | Max key length | **40** characters | | Max value length | **500** characters | Validation runs on every write. Exceeding any limit returns `422 Unprocessable Entity` with a descriptive `error_code: validation_error` envelope. ## Reading metadata Every read response includes `metadata`: ```json theme={null} { "id": "prod_abc123", "name": "Acme Widget", "metadata": { "internal_sku": "AW-12345", "warehouse": "east" } } ``` If you've never set metadata, the field is `{}`. ## Writing metadata Send `metadata` to the regular `PATCH /v1/{resource}/{id}` endpoint. There's no separate metadata sub-endpoint — that's how Stripe does it and we match. ### Set or overwrite a key ```bash theme={null} curl -X PATCH https://www.closient.com/api/v1/products/prod_abc123 \ -H "Authorization: Bearer ck_live_xxx" \ -H "Content-Type: application/json" \ -d '{"metadata": {"internal_sku": "AW-12345"}}' ``` ### Merge semantics — only changed keys are sent `metadata` updates are always **merge**, never replace. Send only the keys you're changing; everything else is preserved. ```json theme={null} {"metadata": {"warehouse": "west"}} ``` This sets `warehouse=west` and leaves any other existing keys untouched. ### Delete a key — empty-string value ```json theme={null} {"metadata": {"deprecated_field": ""}} ``` An empty-string value is the delete sentinel. The key is removed from the object. ### Clear all metadata Two equivalent forms: ```json theme={null} {"metadata": {}} ``` ```json theme={null} {"metadata": null} ``` Both wipe every key on the object. ### Combined: set, delete, leave-alone in one request ```json theme={null} { "metadata": { "internal_sku": "AW-67890", "warehouse": "", "campaign_id": "summer-2026" } } ``` This sets `internal_sku` to a new value, deletes `warehouse`, and adds `campaign_id`. Any other existing key is left alone. ## Form-encoded shorthand For curl-style usage, the `metadata[key]=value` form-encoded shorthand is also accepted: ```bash theme={null} curl -X PATCH https://www.closient.com/api/v1/products/prod_abc123 \ -H "Authorization: Bearer ck_live_xxx" \ -d "metadata[internal_sku]=AW-67890" \ -d "metadata[warehouse]=" ``` A bare `metadata=` (no `[key]`) clears all metadata. ## Webhooks `{resource}.updated` webhook events include the full updated `metadata`. Setting/deleting keys triggers the standard updated event — there's no separate `metadata.updated` event type. ## Resources that support metadata Every CRUD-API resource in the Closient API supports `metadata` as of [C-2699](https://linear.app/closient/issue/C-2699): * Products, Brands, Substances * Retailers, Offers (in-store + online), Promotions * Webhook Endpoints, Resolver Rules * Organizations, Users, API Keys, Service Accounts * Locations, Campaigns, Scan Sessions, QA Feedback Sessions * Certifications and Claims If you find one that doesn't, file an issue — we missed it. ## Common patterns ### Stamp every object with your internal ID ```python theme={null} client.products.update("prod_abc123", metadata={"erp_id": "ERP-12345"}) ``` Then look up the original product later by checking `metadata["erp_id"]`. ### Track migration progress ```python theme={null} client.products.update(product_id, metadata={"migrated_to_v2": "true"}) ``` (Use a string, not a boolean — `metadata` values must be strings.) ### A/B test bucketing ```python theme={null} client.products.update( product_id, metadata={"experiment_2026q2_pricing": "control"}, ) ``` ### Cleanup of stale keys ```python theme={null} client.products.update( product_id, metadata={"old_experiment_id": "", "new_experiment_id": "treatment"}, ) ``` ## What metadata is NOT for * **Identifiers Closient should look up.** If you want us to find an object by your ID, that's a different feature (deep search) — we don't index `metadata` for behavioral lookups. * **Sensitive data.** While transport is HTTPS, `metadata` payloads are stored in our database with the rest of the resource and visible to anyone who can read the resource. Don't put secrets, PII you don't already share with us, or anything you wouldn't put in our `name` field. * **Schema-heavy data.** If you find yourself building rich structures inside `metadata`, that's a sign you want a real schema — talk to us about a first-class field on the resource. # MCP Server vs REST API Source: https://docs.closient.com/prompts/api-vs-mcp When to use the Closient MCP server vs the REST API for AI agent integration, custom backends, and developer tooling. Closient exposes the same product graph through two surfaces: a hosted [Model Context Protocol](https://modelcontextprotocol.io) server and an [HTTPS REST API](/api-reference/overview). They cover the same data; they're optimised for different consumers. ## Should I use the MCP server or the REST API? | If you're... | Use | | - | - | | Building an AI agent (Claude, Cursor, IDE assistant, custom MCP client) | **MCP server** | | Integrating product lookup into a backend service | **REST API** | | Embedding barcode resolution in a mobile or web app | **REST API** | | Connecting a no-code workflow tool (n8n, Make, Zapier) | **REST API** | | Hooking a desktop AI app into product data | **MCP server** | | Building a CLI that humans run interactively | Either — REST is simpler for non-AI tools | The rough rule: **if the consumer is an LLM, use MCP. If the consumer is code you wrote, use REST.** ## What's the difference between an AI agent calling MCP and an AI agent calling the REST API? Both work; the AI client experience is what differs. **MCP** gives the agent a tool list at connect time, with structured input/output schemas and human-readable descriptions. The agent picks tools by name and the LLM doesn't need any "how to call this API" documentation in its prompt — the protocol handles tool discovery, parameter validation, and result framing. **REST** forces you to put the API surface into the agent's prompt (or its function-calling schema), which costs tokens and means every release of the agent needs an updated tool definition. For agents that already speak MCP — Claude Code, Cursor, Claude Desktop, VS Code, Windsurf — MCP is strictly less work. ## What can each surface do? | Capability | MCP | REST | | - | - | - | | Validate a GTIN | `validate_gtin` | `GET /products/api/v1/products?gtin=...` | | Resolve a GTIN | `resolve_gtin` | `GET /01/` | | Look up product data | `lookup_product` | `GET /products/api/v1/products/` | | Natural-language search | `search_products` | `POST /search/api/v1/sessions` | | Compare products | `compare_products` | Multiple `GET /products` calls + client-side join | | Check local availability | `check_availability` | `GET /retailers/api/v1/offers` | | Generate Digital Link QR | `generate_qr_url` (OAuth) | `POST /api/v1/qr-codes` (API key) | | Create / claim a product | `create_product` (OAuth) | `POST /products/api/v1/products` | | Edit basic info, dimensions, GPC | `update_product` (OAuth) | `PATCH /products/api/v1/products/` | | Upload / delete product images | `upload_product_image`, `delete_product_image` (OAuth) | `POST` / `DELETE /dashboard/api/v1/...` | | Upload / edit / delete documents | `upload_product_document`, `update_product_document`, `delete_product_document` (OAuth) | `POST` / `PATCH` / `DELETE /dashboard/api/v1/...` | | Add / edit / delete videos | `add_product_video`, `update_product_video`, `delete_product_video` (OAuth) | `POST` / `PATCH` / `DELETE /dashboard/api/v1/...` | | Set nutrition facts | `set_product_nutrition` (OAuth) | `PUT /dashboard/api/v1/.../nutrition` | | Set ingredients | `set_product_ingredients` (OAuth) | `PATCH /products/api/v1/products/` (`ingredients` field) | | Webhooks / event streams | *Not exposed* | `/integrations/webhooks` | | Bulk operations | *Not exposed* | Pagination + batch endpoints | Both surfaces now cover the full brand-owner product-entry workflow — an agent holding an MCP connection with a `products:write`/`dashboard:write` scoped token can create a product and enter every field a human could enter through the dashboard UI, without switching to REST. See [MCP Tool Reference](/guides/mcp-tools) for the complete write-tool list. Webhooks and true bulk operations (multi-hundred-row imports) remain REST-only — those aren't a good fit for a single LLM tool call. ## How do auth and rate limits compare? | Aspect | MCP | REST | | - | - | - | | Default auth | None (most tools) | API key required | | Authenticated tools / endpoints | OAuth 2.1 step-up | `X-API-Key: __` | | Rate limit | Shared backend limits | 300/min, 10,000/day per key | | Client registration | CIMD / DCR / pre-registration | Dashboard → Settings → API Keys | ## Can I use both? Yes — and many integrations do. A common pattern: * **MCP server** for the agent's read path (lookup, search, resolve). * **REST API** for the backend that owns mutations (catalog edits, webhook subscriptions, bulk imports) where you don't want LLM-induced side effects. Auth is independent — the OAuth tokens minted for the MCP write tool and the API keys minted for the REST API live in different namespaces and have different scopes. ## Where do I go next? One-command install for every supported MCP client. Eighteen domain APIs with interactive playgrounds. First REST API call in under five minutes. First MCP tool call in under two minutes.