---
title: "Compare on-screen placements without an account"
method: POST
path: "/v1/perch/compare"
tags: ["Catalogue"]
---

# Compare on-screen placements without an account

`POST /v1/perch/compare`

## Request

- Base URL: `https://api.placementapi.com`
- URL: `https://api.placementapi.com/v1/perch/compare`
- Auth: none required

Anonymous, read-only exact-domain price discovery for Perch. Uses the same comparison engine and input contract as /v1/baskets/compare. No account or order is created. Bounded to 100 lines per request, 120 requests and 1,000 lines per IP per hour, and a global hourly service ceiling. Unknown terms withhold savings; catalogue estimates are not live supplier quotes. Invalid input is refused before quota or catalogue work. Buying remains a separate explicit checkout action.

## 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`: Malformed request. `error` is `bad_request` (with a zod `detail`), `invalid_json`, or `unknown_parameter` / `unknown_field` (with `unknown[]` and `accepted[]`).
- `413`: Payload too large
- `429`: Public comparison limit reached
- `503`: Comparison unavailable

---

[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)
