Every API here is available over the APIs.io API and to AI agents over MCP.
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.'