---
title: "What a captured basket would cost here"
method: POST
path: "/v1/baskets/compare"
tags: ["Comparison"]
---

# What a captured basket would cost here

`POST /v1/baskets/compare`

## Request

- Base URL: `https://api.placementapi.com`
- URL: `https://api.placementapi.com/v1/baskets/compare`
- Auth: HTTP bearer

Read-only comparison of a basket a buyer captured on another marketplace. Nothing is ordered, held, reserved or written, and no captured price is stored. The result is a catalogue-price ESTIMATE against our live named inventory — never a vendor-confirmed price, a guaranteed quote, or a claim that the source vendor's figure was verified.

Read authority only: an authenticated customer session or an API key carrying the `read` scope. The operator token is refused (403). Metering runs after authentication and before any catalogue or price work, charged one row per input line, so a 100-line basket costs 100 rows of read budget; a per-(domain, type) candidate cap, a whole-request candidate budget and a bounded resolve concurrency keep the price work bounded, and an overflowed pair is stated on its line rather than silently truncated into a cheapest claim.

Each line matches EXACTLY the same canonical domain and the same placement type as a live, non-delisted, named offer. A publisher is never substituted silently: no match is `unmatched`, not the nearest alternative. A line is `comparable` only when the terms known on BOTH sides agree — the writing term and the link attribute must each be known and equal (writing does not apply to a link placed into an existing page). A cheaper offer whose known terms disagree is an `alternative` whose `estimated_savings_usd_minor` is null; a different placement is not a cheaper placement. Restricted domains stay visible but are flagged for review and are never counted as comparable. The candidate price is all-in — the placement price plus the content fee the resolved plan carries — in credits (1 credit = 1 US cent), priced through the same eligibility and content machinery POST /v1/orders and the basket checkout use.

Numeric savings are USD-only and all-in-only. A non-USD source price stays visible but is never converted (no exchange rate is invented) and carries no numeric saving; a captured price not flagged `source_all_in` withholds a numeric saving rather than possibly overstating it; and an unknown term is never promoted to a known one. The `matched_*` summary figures cover only the comparable lines — `matched_estimated_savings_usd_minor` is the saving on matched lines only — while `whole_basket_estimated_savings_usd_minor` folds in `basket_adjustment_usd_minor` and is null unless every line is comparable, so a partial basket is never presented as a whole-basket saving. Prices are scrapes, not live quotes: `candidate.observed_at` carries the age of the figure and an aged observation is quoted as observed rather than presented as current. No supplier name, cost or internal id is ever serialised; `candidate.offer_id` is the public, pseudonymised id.

## Request body

- BasketCompareRequest: A captured basket for POST /v1/baskets/compare, built by the Perch extension or the hosted compare-basket page and posted unchanged. The panel is strict: an unrecognised field is refused rather than ignored, so a partly understood basket cannot be compared as though it were complete. Nothing here is stored.
  - `lines` BasketCompareLineInput[], required: 1..100 captured placements. Every `id` must be unique within the request.
    - `id` string, required: Caller-chosen line id, echoed on the result. Unique within the request; a repeat is a 400 `duplicate_line_id`.
    - `domain` string, required: The captured domain or URL. A scheme or path is tolerated; it is canonicalised to the same site id the catalogue uses, so `www.` and `blog.` are never confused.
    - `placement_type` string, required: Only `guest_post`, `niche_edit` and `link_insert` are compared. Any other value is answered on the line as `unsupported` rather than failing the whole request, so one odd row does not sink the basket.
    - `quantity` integer, required: Placements for this line. Retained exactly; no deduplication that would drop multiple placements.
    - `observed_unit_usd_minor` integer, nullable, required: The captured unit price in USD minor units (cents), or null when the capture had no unambiguous USD price. Null (or any non-USD `currency`) makes the line ineligible for a numeric saving while still returning the comparison. Bounded so every aggregate is an exact safe integer; out of range is a 400, never clamped.
    - `currency` string, required: The captured ISO 4217 code, upper-cased by the engine. Anything other than `USD` stays visible but is not converted — no exchange rate is invented — and is ineligible for a numeric saving.
    - `writing` 'included' | 'supplied' | 'unknown', required: Who provides the article. `unknown` never matches a known offer term and never becomes a default.
    - `link_attribute` 'dofollow' | 'nofollow' | 'sponsored' | 'unknown', required: The captured link attribute. `unknown` never matches; `sponsored` is honoured as captured and never inferred on the offer side.
    - `source_all_in` boolean, required: Whether the captured price already includes every fee. A line that is not flagged all-in withholds a numeric saving rather than possibly overstating it; it never disqualifies the line from being shown.
  - `basket_adjustment_usd_minor` integer: Whole-basket discount (negative) or surcharge (positive) in USD minor units, applied only to the whole-basket saving. It is never folded into the per-line or matched-line figures, and a discount may not push a fully known source total below zero.

## Response `200`

OK

- BasketCompareResponse: The full comparison. Fields are added, never renamed. A failure is never returned as an empty or zero-savings body.
  - `version` 1, required
  - `lines` BasketCompareLine[], required: One result per input line, in input order.
    - `id` string, required: The caller's line id, echoed.
    - `domain` string, required: The captured domain, echoed as given.
    - `status` 'comparable' | 'alternative' | 'unmatched' | 'unsupported', required: `comparable`: exact match and savings may be claimed. `alternative`: a candidate is shown but a term differs, the site is restricted, the captured price is non-USD or not all-in, or the pair overflowed — no saving is claimed. `unmatched`: no live named offer on that exact domain and type (never a silent substitution). `unsupported`: the placement type is not compared.
    - `candidate` BasketCompareCandidate, required: The cheapest term-compatible live named offer for the line, priced all-in. A catalogue-price estimate as observed (see `observed_at`), never a vendor-confirmed price or guaranteed quote. Supplier identity is intentionally not exposed anywhere in this API. You are buying from us; which upstream marketplace fulfils a placement is not something you need or can act on.
      - `offer_id` string: The public, pseudonymised offer id (the same form the pricing resolver accepts). The internal supplier-scoped id is never returned.
      - `placement_type` string: The offer's placement type; it equals the captured type, since only the same type is compared.
      - `unit_credits` integer: Credits. 1 credit = 1 US cent. ALL-IN per placement: the placement price plus the content fee the resolved plan carries. This is the figure the buyer-credit total is built from.
      - `content_mode` 'publisher' | 'service' | 'supplied': How the resolved plan sources the article: the publisher writes, we arrange a paid service, or the buyer supplies copy. (`papi_written` is reported here as `service`.)
      - `content_tier` string, nullable: The paid writing tier by name when `content_mode` is `service`; null otherwise.
      - `observed_at` string, date-time, nullable: When the catalogue row was observed. The age of the price travels with it: this is a scrape, not a live quote, so an aged figure is quoted as observed rather than presented as current.
      - `link_attribute` string: The offer's known link attribute, or `unknown` when the listing did not record one. A `dofollow: false` listing is `nofollow` and nothing more; `sponsored` is never inferred.
      - `writing` 'included' | 'supplied' | 'unknown': The writing term the resolved plan actually delivers; `unknown` for a link-only placement, where writing does not apply.
    - `reasons` string[], required: Human-readable explanation for the status and saving, including any overflow, restricted-flag or term-mismatch note. Not part of a machine contract.
    - `observed_total_usd_minor` integer, nullable, required: The captured unit price times the quantity, in USD minor units, or null when no unambiguous USD price was captured. Shown even for ineligible lines so the buyer sees their own figure.
    - `candidate_total_credits` integer, nullable, required: All-in candidate cost for the line's full quantity, in credits (1 credit = 1 US cent): `candidate.unit_credits` × quantity. Null when no candidate was priced.
    - `estimated_savings_usd_minor` integer, nullable, required: `observed_total_usd_minor` minus `candidate_total_credits`, present only for a `comparable` line. Null for every alternative, unmatched or unsupported line, so a shown candidate never carries a saving it did not earn. A negative value is retained (the candidate costs more), not clamped.
  - `summary` BasketCompareSummary, required: Basket-level totals. `matched_*` fields cover the comparable lines only; the whole-basket saving additionally requires every line to be comparable. A matched subset is never presented as a full-basket saving.
    - `line_count` integer, required: Lines in the request.
    - `comparable_line_count` integer, required: Lines whose status is `comparable`.
    - `source_total_usd_minor` integer, nullable, required: Sum of every line's captured USD total, or null when any line had no unambiguous USD price. Never a converted or estimated total.
    - `matched_source_total_usd_minor` integer, required: Sum of the captured totals of the comparable lines only.
    - `matched_candidate_total_credits` integer, required: Sum of the all-in candidate totals of the comparable lines only, in credits.
    - `matched_estimated_savings_usd_minor` integer, required: Savings on the comparable lines only. The cart adjustment is NOT folded in here.
    - `whole_basket_estimated_savings_usd_minor` integer, nullable, required: Matched savings plus `basket_adjustment_usd_minor`, or null unless every line is comparable. Null is why a partial basket never shows a whole-basket figure.
  - `calculated_at` string, date-time, required: When this comparison was computed.

## Other responses

- `400`: `bad_request` (with a zod `detail`), `invalid_json`, `duplicate_line_id` (with `id`), or `invalid_input` / `invalid_adjustment` when an engine bound is violated or a discount would push a known source total negative. Aggregates are exact safe integers; out-of-range input is refused, never clamped.
- `401`: Missing, malformed or revoked Bearer credential: `{ error: "unauthorized" }`.
- `403`: `insufficient_scope` when the key lacks the `read` scope; `customer_key_required` for the operator token.
- `413`: `payload_too_large`: the request body exceeded 512 KiB. A legitimate 150k-character fragment-derived basket is far smaller.
- `429`: Rate limited. 1,000 requests and 20,000 returned rows per key per hour, a monthly row entitlement that grows with credits purchased, and a maximum offset of 10,000. Carries `Retry-After` and `X-RateLimit-*` / `X-Quota-Rows-*` headers.
- `503`: `server_misconfigured` when the store is not configured; `comparison_unavailable` on any catalogue or pricing failure. A failure is never laundered into an empty or zero-savings comparison.

---

[API](https://skmtc.dev/placementapi/apis/placement-api.md) · [All operations](https://skmtc.dev/placementapi/apis/placement-api/llms.txt) · [OpenAPI document](https://skmtc.dev/placementapi/apis/placement-api/revisions/fc4e7866eaf7?raw)
