Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Placement Orders 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: Orders
description: Place, list, cancel and follow orders. Requires the `order` scope to spend.
paths:
/v1/orders:
post:
tags:
- Orders
operationId: place_order
summary: Place an order (pay-first)
description: 'Debits credits immediately. Requires the `order` scope and an Idempotency-Key header. With quote_id the held price is charged instead of the live one and the freshness guard is bypassed; without it a stale catalogue price is refused. Content is decided now against what the listing allows: ''supplied'' without inline `content` parks the order in awaiting_content for 14 days; ''papi_written'' charges 1,500 credits with the order; inline content is validated first (422, nothing charged). Orders that carry an article are one placement each.'
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- offer_id
- target_url
properties:
offer_id:
type: string
description: From /v1/offers or a quote line / alternative.
quote_id:
type: string
description: Redeem a held price from POST /v1/quote or POST /v1/targets/gap.
target_url:
type: string
format: uri
description: The page the link points at.
anchor_text:
type:
- string
- 'null'
minLength: 1
maxLength: 300
description: Omit when order_requirements marks it optional (publisher writes the anchor).
target_keyword:
type:
- string
- 'null'
maxLength: 200
description: 'The query the page should rank for. Optional and worth sending: it seeds outcome tracking and anchor classification.'
target_market:
type:
- string
- 'null'
minLength: 2
maxLength: 2
description: ISO country for the SERP this is judged in. Defaults to US.
target_device:
type:
- string
- 'null'
enum:
- desktop
- mobile
- null
anchor_plan:
$ref: '#/components/schemas/AnchorPlan'
quantity:
type: integer
minimum: 1
maximum: 100
default: 1
content_mode:
type: string
enum:
- publisher
- service
- supplied
- papi_written
description: 'Defaults from order_requirements.content: ''publisher'' where the publisher writes, else ''supplied''. Refused (400 content_not_accepted) where the listing does not allow it.'
content_tier:
type: string
maxLength: 80
description: 'For ''service'': a vendor_label (or tier) from order_requirements.content.options. Defaults to the cheapest paid tier.'
if_no_content:
type: string
enum:
- convert_to_service
- cancel
default: convert_to_service
description: What happens when supplied copy misses content.due_at.
content:
$ref: '#/components/schemas/ContentBody'
responses:
'200':
description: Idempotent replay of an earlier order with the same key.
content:
application/json:
schema:
type: object
properties:
order_id:
type: string
replayed:
type: boolean
const: true
additionalProperties: true
'201':
description: Placed.
content:
application/json:
schema:
type: object
properties:
order_id:
type: string
replayed:
type: boolean
const: false
status:
type: string
enum:
- ordered
- awaiting_content
unit_price_credits:
type: integer
total_credits:
type: integer
description: unit price x quantity + content price.
content:
type: object
properties:
mode:
type: string
enum:
- publisher
- service
- supplied
- papi_written
tier:
type:
- string
- 'null'
price_credits:
type: integer
due_at:
type:
- string
- 'null'
if_no_content:
type:
- string
- 'null'
fallback_price_credits:
type:
- integer
- 'null'
submit:
type:
- string
- 'null'
description: POST /v1/orders/{order_id}/content when the buyer owes the article.
spec:
$ref: '#/components/schemas/ContentSpec'
validation:
oneOf:
- $ref: '#/components/schemas/ContentValidation'
- type: 'null'
inventory_mode:
$ref: '#/components/schemas/InventoryMode'
site_id:
type:
- string
- 'null'
note:
type: string
description: 'Present on blind and pre_approval inventory: says plainly that no specific domain was sold.'
requires_approval:
type: boolean
description: True on pre_approval inventory.
'400':
description: '`idempotency_key_required`, `invalid_json`, `bad_request`, `content_not_accepted`, `no_service_tier`, `one_article_per_order`, `unknown_content_tier`, `content_not_expected`.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'402':
description: '`insufficient_credits`, with unit_price_credits and content_price_credits.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: '`insufficient_scope`, `customer_key_required`, or a ledger refusal: `per_order_cap_exceeded`, `daily_cap_exceeded`, `customer_inactive`, `customer_not_found`.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: '`offer_not_found`, `quote_not_found`.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: '`offer_delisted`, `offer_not_priceable`, `quote_expired` (with expires_at), `offer_not_in_quote`, `price_stale` (with observed_at).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'422':
description: '`content_invalid`: the inline article would not pass the publisher; `validation` lists the problems. Nothing was charged.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
$ref: '#/components/responses/Unavailable'
get:
tags:
- Orders
operationId: list_orders
summary: Your orders
description: Newest first, with delivery state derived at read time, our latest link audit, and a compact observed outcome. `since` matches orders placed OR delivered at or after it (inclusive); a cancellation of an older order is not surfaced by it — re-read pending orders.
parameters:
- name: status
in: query
required: false
schema:
$ref: '#/components/schemas/OrderStatus'
description: One order status.
- name: since
in: query
required: false
schema:
type: string
format: date-time
description: ISO-8601 timestamp.
- name: target_url
in: query
required: false
schema:
type: string
format: uri
description: Exact URL the links point at.
- name: target_domain
in: query
required: false
schema:
type: string
description: Any order pointing anywhere on this domain.
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
description: Rows.
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
count:
type: integer
filters:
type: object
description: The filters as applied.
orders:
type: array
items:
$ref: '#/components/schemas/Order'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/Unavailable'
/v1/orders/{order_id}/cancel:
post:
tags:
- Orders
operationId: cancel_order
summary: Cancel before it reaches a supplier
description: Pulls an order back and refunds it in full, in the same statement. Orders are batched to suppliers each morning, so this is the window in which a mistake is cheaply reversible. Requires the `order` scope.
parameters:
- name: order_id
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
order_id:
type: string
status:
type: string
const: cancelled
credits_returned:
type: integer
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: '`not_found`: no cancellable order with that id (including another customer''s).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: '`already_submitted`: the order has reached the supplier, so it can no longer be cancelled before fulfilment. Nothing is filed with a supplier automatically; a customer can ask us to pursue a supplier recovery review at hello@placementapi.com, and credits are then returned once we have recovered from the supplier and their refund window plus the account grace period have run, or earlier as a goodwill decision.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
$ref: '#/components/responses/Unavailable'
/v1/orders/{order_id}/outcome:
get:
tags:
- Orders
operationId: order_outcome
summary: Everything observed since one placement
description: Per-keyword rank series across checkpoints, SERP churn, visibility before/after, survival checks, and a confidence with a note. Recomputed on read when the stored row is over an hour old. `outcome` is null with a note until tracking has started.
parameters:
- name: order_id
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
order_id:
type: string
status:
$ref: '#/components/schemas/OrderStatus'
outcome:
$ref: '#/components/schemas/Outcome'
note:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: '`order_not_found`.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
$ref: '#/components/responses/Unavailable'
components:
schemas:
Outcome:
type:
- object
- 'null'
description: 'Everything observed since a placement. Checkpoints: baseline_order, baseline_delivery, d7, d30, d60, d90, d180, d365. Observational: a rank that moved is a fact; that this link moved it is not established. Never say ''caused''.'
properties:
order_id:
type: string
target_url:
type: string
site_id:
type:
- string
- 'null'
delivered_at:
type:
- string
- 'null'
days_observed:
type:
- integer
- 'null'
latest_checkpoint:
type:
- string
- 'null'
keywords:
type: array
items:
$ref: '#/components/schemas/OutcomeKeyword'
keywords_tracked:
type: integer
keywords_improved:
type: integer
keywords_declined:
type: integer
entered_top10:
type: integer
median_delta:
type:
- number
- 'null'
volatility_proxy:
type:
- number
- 'null'
serp_churn:
type:
- number
- 'null'
description: Share of the baseline top-10 domains no longer in the latest top 10.
visibility:
type: object
properties:
baseline_traffic:
type:
- number
- 'null'
latest_traffic:
type:
- number
- 'null'
baseline_keywords:
type:
- integer
- 'null'
latest_keywords:
type:
- integer
- 'null'
survival:
type: object
properties:
live:
type:
- boolean
- 'null'
checks:
type: integer
last_checked:
type:
- string
- 'null'
first_missing_at:
type:
- string
- 'null'
confidence:
type: string
enum:
- low
- medium
- high
description: 'About the window, not the size of the movement: under 30 days is noise; one keyword is an anecdote; churn ≥50% says the SERP moved.'
confidence_note:
type: string
computed_at:
type: string
format: date-time
PlacementType:
type: string
enum:
- guest_post
- niche_edit
- link_insert
- homepage_link
- media_placement
AnchorPlan:
type: object
description: Anchor text is a DISTRIBUTION, not a string. Many links with one exact-match anchor is the classic over-optimisation footprint. Mix branded, topical, generic and naked_url anchors.
required:
- anchors
properties:
anchors:
type: array
minItems: 1
items:
type: object
required:
- text
- type
properties:
text:
type: string
minLength: 1
maxLength: 300
type:
type: string
enum:
- branded
- naked_url
- generic
- topical
- partial_match
- exact_match
weight:
type: number
exclusiveMinimum: 0
default: 1
description: Relative weight; normalised across the plan.
ContentSpec:
type:
- object
- 'null'
description: The brief to write against when the buyer supplies copy. Null when copy cannot be supplied.
properties:
word_count_min:
type:
- integer
- 'null'
word_count_max:
type:
- integer
- 'null'
links_allowed:
type: integer
anchor_types_forbidden:
type: array
items:
type: string
rules:
type: array
items:
type: string
description: Editorial rules in the publisher's own words.
ContentValidation:
type: object
description: Will this pass the publisher? Word count against the spec, external links against the allowance, the target linked exactly once with the anchor the order names, no forbidden anchor type.
properties:
ok:
type: boolean
word_count:
type:
- integer
- 'null'
links_found:
type: integer
target_link_found:
type: boolean
problems:
type: array
items:
type: string
warnings:
type: array
items:
type: string
ContentBody:
type: object
description: An article. Send body_markdown, body_html or doc_url. A doc_url alone cannot be validated and is accepted with a warning.
properties:
title:
type:
- string
- 'null'
maxLength: 300
body_markdown:
type:
- string
- 'null'
maxLength: 200000
body_html:
type:
- string
- 'null'
maxLength: 400000
doc_url:
type:
- string
- 'null'
format: uri
maxLength: 2000
meta_description:
type:
- string
- 'null'
maxLength: 400
InventoryMode:
type: string
enum:
- named
- pre_approval
- blind
description: The most important field. 'named' = a specific, identified domain you can inspect before ordering. 'pre_approval' = the vendor proposes candidate domains and the buyer approves them BEFORE placement. 'blind' = a promise (e.g. DR50+, finance) where the domain is assigned at fulfilment with no veto. Only 'named' carries a site_id. Never present a pre_approval or blind offer as though a specific site were secured, and never price-compare a specification against a named domain as the same product.
OutcomeKeyword:
type: object
properties:
keyword:
type: string
baseline:
type:
- integer
- 'null'
latest:
type:
- integer
- 'null'
latest_checkpoint:
type:
- string
- 'null'
delta:
type:
- integer
- 'null'
description: 'baseline − latest: positive means it moved up.'
best:
type:
- integer
- 'null'
series:
type: array
items:
type: object
properties:
checkpoint:
type: string
rank:
type:
- integer
- 'null'
observed_at:
type: string
OrderContent:
type: object
description: 'The article on an order: who writes it, what it cost, when it is due.'
properties:
mode:
type: string
enum:
- publisher
- service
- supplied
- papi_written
tier:
type:
- string
- 'null'
price_credits:
type: integer
due_at:
type:
- string
- 'null'
format: date-time
if_no_content:
type:
- string
- 'null'
enum:
- convert_to_service
- cancel
- null
version:
type: integer
description: How many versions have been sent so far.
awaiting:
type: boolean
description: 'True in awaiting_content or content_revision_requested: the order is paid and held, not with a supplier.'
OrderStatus:
type: string
enum:
- ordered
- awaiting_content
- content_revision_requested
- awaiting_operator
- submitted_to_vendor
- delivered
- cancelled
- refunded
- failed
Order:
type: object
description: An order as listed by GET /v1/orders. 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:
order_id:
type: string
status:
$ref: '#/components/schemas/OrderStatus'
offer_id:
type: string
placement_type:
$ref: '#/components/schemas/PlacementType'
inventory_mode:
$ref: '#/components/schemas/InventoryMode'
site_id:
type:
- string
- 'null'
target_url:
type: string
anchor_text:
type:
- string
- 'null'
anchor_plan:
oneOf:
- $ref: '#/components/schemas/AnchorPlan'
- type: 'null'
target_keyword:
type:
- string
- 'null'
quantity:
type: integer
total_credits:
type: integer
description: Credits. 1 credit = 1 US cent.
ordered_at:
type: string
format: date-time
due_at:
type:
- string
- 'null'
format: date-time
content:
$ref: '#/components/schemas/OrderContent'
cancellable:
type: boolean
description: True while nothing has been sent to a supplier yet.
submitted_at:
type:
- string
- 'null'
format: date-time
delivered_at:
type:
- string
- 'null'
format: date-time
placement_url:
type:
- string
- 'null'
delivery_state:
type: string
enum:
- in_progress
- on_time
- delivered_late
- overdue
- refund_eligible
- unknown_turnaround
- closed
description: Derived at read time, never stored. `refund_eligible` is a delivery-timing milestone (past due plus the account grace period) judged from the calendar alone; it does not check supplier recovery, does not mean a claim has been filed with a supplier, and is not a refund decision.
days_late:
type:
- integer
- 'null'
refund_eligible_at:
type:
- string
- 'null'
format: date-time
description: 'When the order passes due_at plus the account grace period. Delivery timing only: it does not mean credits have been returned and it is not an automatic credit refund. A credit refund for an order already with a supplier requires that we have recovered from the supplier, plus the supplier''s refund window and the account grace period. The customer can ask us to pursue a supplier recovery review at hello@placementapi.com.'
supplier_report:
type:
- string
- 'null'
description: 'The supplier''s latest word from the check-in loop: completed | on_time | delayed, or null.'
supplier_report_at:
type:
- string
- 'null'
link_audit:
type:
- object
- 'null'
description: 'Our own audit of the delivered page: link present in served HTML, dofollow, bought anchor intact. Null before the first sweep.'
properties:
checked_at:
type: string
link_found:
type: boolean
dofollow:
type:
- boolean
- 'null'
anchor_matched:
type:
- boolean
- 'null'
turnaround_revised:
type: boolean
outcome:
type:
- object
- 'null'
description: Compact outcome (no per-keyword series). `detail` names GET /v1/orders/{order_id}/outcome. Null until tracking has produced anything.
properties:
days_observed:
type:
- integer
- 'null'
latest_checkpoint:
type:
- string
- 'null'
keywords:
type: array
items:
type: object
keywords_improved:
type: integer
keywords_declined:
type: integer
entered_top10:
type: integer
median_delta:
type:
- number
- 'null'
serp_churn:
type:
- number
- 'null'
visibility:
type: object
survival:
type: object
confidence:
type: string
enum:
- low
- medium
- high
confidence_note:
type: string
detail:
type: string
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
responses:
Unavailable:
description: '`server_misconfigured` when the store is not configured; `upstream_unfunded` when the measurement provider''s prepaid balance is exhausted (fresh profiles, gap maps and SERPs pause; cached results and catalogue are unaffected); `upstream_unavailable` on a provider error; `payments_unavailable` when card payments are not configured.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: '`insufficient_scope` when the key lacks the `order` scope; `customer_key_required` when the operator token is used where a customer must be attributable.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
BadRequest:
description: Malformed request. `error` is `bad_request` (with a zod `detail`), `invalid_json`, or `unknown_parameter` / `unknown_field` (with `unknown[]` and `accepted[]`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: 'Missing, malformed or revoked Bearer credential: `{ error: "unauthorized" }`.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema:
type: string
description: 'Required. A retry without one is indistinguishable from a second purchase and is refused (400 idempotency_key_required) rather than double-charged. A replay with the same key returns the original result with `replayed: true`.'
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.'