placementapi-com Comparison API

Read-only comparison of a basket captured elsewhere (Perch). No ordering, no writes.

Operations 1

POST /v1/baskets/compare What a captured basket would cost here #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/placementapi-com:placementapi-com-comparison-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

placementapi-com-comparison-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  title: Placement Comparison API
  version: 1.0.0
  description: 'Normalised backlink placement inventory, deduplicated across multiple suppliers, with independent verification, held quotes, ordering, content handling and post-placement outcome tracking. Every price is the buyer price in credits (1 credit = 1 US cent), already normalised from supplier currencies, so the figure you plan with is the figure you are charged. Supplier identity is never exposed.


    Authenticate with a `pk_live_…` API key as a Bearer token. Keys carry scopes: `read` (catalogue, quotes, account reads) and `order` (anything that moves money). An MCP server exposing the same operations as tools is available at https://api.placementapi.com/mcp with the same Bearer key; the tools call these endpoints, so pricing and verification are identical either way. Unknown query parameters and brief fields are refused with 400 rather than ignored.'
  contact:
    email: hello@placementapi.com
  termsOfService: https://placementapi.com/terms
servers:
- url: https://api.placementapi.com
security:
- apiKey: []
tags:
- name: Comparison
  description: Read-only comparison of a basket captured elsewhere (Perch). No ordering, no writes.
paths:
  /v1/baskets/compare:
    post:
      tags:
      - Comparison
      operationId: compare_basket
      summary: What a captured basket would cost here
      description: '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.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BasketCompareRequest'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BasketCompareResponse'
        '400':
          description: '`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.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`insufficient_scope` when the key lacks the `read` scope; `customer_key_required` for the operator token.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: '`payload_too_large`: the request body exceeded 512 KiB. A legitimate 150k-character fragment-derived basket is far smaller.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: '`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.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    BasketCompareCandidate:
      type: object
      description: 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.
      properties:
        offer_id:
          type: string
          description: The public, pseudonymised offer id (the same form the pricing resolver accepts). The internal supplier-scoped id is never returned.
        placement_type:
          type: string
          description: The offer's placement type; it equals the captured type, since only the same type is compared.
        unit_credits:
          type: integer
          description: '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:
          type: string
          enum:
          - publisher
          - service
          - supplied
          description: '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:
          type:
          - string
          - 'null'
          description: The paid writing tier by name when `content_mode` is `service`; null otherwise.
        observed_at:
          type:
          - string
          - 'null'
          format: date-time
          description: '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:
          type: string
          description: '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:
          type: string
          enum:
          - included
          - supplied
          - unknown
          description: The writing term the resolved plan actually delivers; `unknown` for a link-only placement, where writing does not apply.
    BasketCompareLineInput:
      type: object
      additionalProperties: false
      required:
      - id
      - domain
      - placement_type
      - quantity
      - observed_unit_usd_minor
      - currency
      - writing
      - link_attribute
      - source_all_in
      description: One captured row. Terms the capture could not determine are carried as `unknown`; they are never filled in with a default.
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 200
          description: Caller-chosen line id, echoed on the result. Unique within the request; a repeat is a 400 `duplicate_line_id`.
        domain:
          type: string
          minLength: 1
          maxLength: 253
          description: 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:
          type: string
          minLength: 1
          maxLength: 40
          description: 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:
          type: integer
          minimum: 1
          maximum: 20
          description: Placements for this line. Retained exactly; no deduplication that would drop multiple placements.
        observed_unit_usd_minor:
          type:
          - integer
          - 'null'
          minimum: 1
          maximum: 1000000000
          description: 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:
          type: string
          minLength: 1
          maxLength: 10
          description: 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:
          type: string
          enum:
          - included
          - supplied
          - unknown
          description: Who provides the article. `unknown` never matches a known offer term and never becomes a default.
        link_attribute:
          type: string
          enum:
          - dofollow
          - nofollow
          - sponsored
          - unknown
          description: The captured link attribute. `unknown` never matches; `sponsored` is honoured as captured and never inferred on the offer side.
        source_all_in:
          type: boolean
          description: 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.
    BasketCompareSummary:
      type: object
      required:
      - line_count
      - comparable_line_count
      - source_total_usd_minor
      - matched_source_total_usd_minor
      - matched_candidate_total_credits
      - matched_estimated_savings_usd_minor
      - whole_basket_estimated_savings_usd_minor
      description: 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.
      properties:
        line_count:
          type: integer
          description: Lines in the request.
        comparable_line_count:
          type: integer
          description: Lines whose status is `comparable`.
        source_total_usd_minor:
          type:
          - integer
          - 'null'
          description: 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:
          type: integer
          description: Sum of the captured totals of the comparable lines only.
        matched_candidate_total_credits:
          type: integer
          description: Sum of the all-in candidate totals of the comparable lines only, in credits.
        matched_estimated_savings_usd_minor:
          type: integer
          description: Savings on the comparable lines only. The cart adjustment is NOT folded in here.
        whole_basket_estimated_savings_usd_minor:
          type:
          - integer
          - 'null'
          description: 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.
    BasketCompareResponse:
      type: object
      required:
      - version
      - lines
      - summary
      - calculated_at
      description: The full comparison. Fields are added, never renamed. A failure is never returned as an empty or zero-savings body.
      properties:
        version:
          type: integer
          const: 1
        lines:
          type: array
          items:
            $ref: '#/components/schemas/BasketCompareLine'
          description: One result per input line, in input order.
        summary:
          $ref: '#/components/schemas/BasketCompareSummary'
        calculated_at:
          type: string
          format: date-time
          description: When this comparison was computed.
    BasketCompareRequest:
      type: object
      additionalProperties: false
      required:
      - lines
      description: '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.'
      properties:
        lines:
          type: array
          minItems: 1
          maxItems: 100
          description: 1..100 captured placements. Every `id` must be unique within the request.
          items:
            $ref: '#/components/schemas/BasketCompareLineInput'
        basket_adjustment_usd_minor:
          type: integer
          default: 0
          minimum: -1000000000
          maximum: 1000000000
          description: 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.
    Error:
      type: object
      required:
      - error
      properties:
        error:
          type: string
          description: Machine-readable code, snake_case.
        detail:
          description: Human-readable explanation, or a zod flatten() object on validation failures.
      additionalProperties: true
    BasketCompareLine:
      type: object
      required:
      - id
      - domain
      - status
      - candidate
      - reasons
      - observed_total_usd_minor
      - candidate_total_credits
      - estimated_savings_usd_minor
      description: One compared line. A `comparable` line is an exact same-domain + same-type + known-term match; an `alternative` keeps a candidate that is shown but not claimed as the same placement; `unmatched` and `unsupported` carry no candidate.
      properties:
        id:
          type: string
          description: The caller's line id, echoed.
        domain:
          type: string
          description: The captured domain, echoed as given.
        status:
          type: string
          enum:
          - comparable
          - alternative
          - unmatched
          - unsupported
          description: '`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:
          oneOf:
          - $ref: '#/components/schemas/BasketCompareCandidate'
          - type: 'null'
          description: The chosen candidate, or null when the line is unmatched or unsupported (and null on an overflowed pair, where no cheapest is claimed).
        reasons:
          type: array
          items:
            type: string
          description: 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:
          type:
          - integer
          - 'null'
          description: 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:
          type:
          - integer
          - 'null'
          description: '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:
          type:
          - integer
          - 'null'
          description: '`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.'
  responses:
    Unauthorized:
      description: 'Missing, malformed or revoked Bearer credential: `{ error: "unauthorized" }`.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: 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.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: 'Customer API key (`pk_live_…`) in `Authorization: Bearer <key>`. Issue from POST /v1/keys or the dashboard. Endpoints marked session-only reject API keys.'