Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Placement Search Console 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: Search Console
description: A customer's own retained Search Console rows. Read-only, never shared.
paths:
/v1/gsc/properties:
get:
tags:
- Search Console
operationId: gsc_properties
summary: Your Search Console properties and selection
description: 'The properties your Google connection can see, with your selection and each property''s pull state. Read-only unless `refresh=1`, which re-asks Google for the current property list. `pull_state` is additive: `queued`/`running` mean an immediate first pull is in progress, `done` means the requested window was processed under Google''s existing row caps (not a complete Google total), `failed` is a terminal permission/revocation outcome, and null means the property is unselected or has never been requested. A property pulled before the first-pull queue existed derives `done` from `pulled_through` when it has no queue state of its own. Read with a customer key (`read` scope) or a dashboard session; property names are private, so every response is `private, no-store` and private property URLs are never logged. Every row is scoped to your account.'
parameters:
- name: refresh
in: query
required: false
schema:
type: string
enum:
- '1'
description: Re-ask Google for the current property list. Retires properties that vanished; keeps your selection.
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
properties:
type: array
items:
type: object
required:
- property
- permission_level
- selected
- pulled_through
- last_pull_at
- last_error
- pull_state
properties:
property:
type: string
description: 'Exactly as Google names it: `sc-domain:example.com` or `https://www.example.com/`.'
permission_level:
type:
- string
- 'null'
description: siteOwner | siteFullUser | siteRestrictedUser | siteUnverifiedUser, or null.
selected:
type: boolean
description: Whether the nightly pull keeps this property current.
pulled_through:
type:
- string
- 'null'
format: date
description: Last day actually pulled, or null.
last_pull_at:
type:
- string
- 'null'
format: date-time
description: Most recent pull attempt, which may have failed; null if never.
last_error:
type:
- string
- 'null'
description: Error from the most recent attempt, or null. Immediate first pulls use short error codes.
pull_state:
type:
- string
- 'null'
enum:
- queued
- running
- done
- failed
- null
description: 'Additive immediate-first-pull state. `queued`/`running`: an immediate import is pending or in progress. `done`: the requested window was processed under Google''s existing row caps — not a complete Google total. `failed`: terminal permission/revocation outcome. null: unselected or never requested. A property pulled before the queue existed derives `done` from `pulled_through` when it has no queue state of its own.'
selected:
type: array
items:
type: string
description: The selected property names, in the same order as `properties`.
next:
type: string
description: A short hint about what happens next; wording is not part of the contract.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: '`insufficient_scope` when the key lacks the `read` scope.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: '`not_connected`: no Google connection for this account.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
description: '`server_misconfigured`, or `gsc_not_configured` when `refresh=1` and the Google client is not configured.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
patch:
tags:
- Search Console
operationId: gsc_select_properties
summary: Choose which properties to pull
description: 'Set which properties the nightly pull keeps current. Selecting a property that has never been pulled queues an immediate first pull: the response returns at once with its additive `pull_state`, and a bounded background task imports the last 180 days (or a larger configured backfill) for your account only. The queue is durable — a five-minute recovery sweep finishes anything the background task does not, and the token-free selection write means repeat submissions never reset a queued, running, failed or completed job, its retry timer or its fixed window. A `failed` job is retried only after an explicit Google reconnect, never by resubmitting the same selection. A missing Google configuration does not discard the queue or report the import complete. Requires the customer key''s `read` scope; a revoked connection is refused with `reconnect_required` until it is reconnected, and the response is `private, no-store`. Unknown properties are refused; the daily read contract is unchanged.'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- selected
additionalProperties: false
properties:
selected:
type: array
maxItems: 50
items:
type: string
minLength: 1
maxLength: 500
description: The exact property names to keep selected. Properties not listed are deselected; their stored rows are kept.
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
properties:
type: array
items:
type: object
required:
- property
- permission_level
- selected
- pulled_through
- last_pull_at
- last_error
- pull_state
properties:
property:
type: string
description: 'Exactly as Google names it: `sc-domain:example.com` or `https://www.example.com/`.'
permission_level:
type:
- string
- 'null'
description: siteOwner | siteFullUser | siteRestrictedUser | siteUnverifiedUser, or null.
selected:
type: boolean
description: Whether the nightly pull keeps this property current.
pulled_through:
type:
- string
- 'null'
format: date
description: Last day actually pulled, or null.
last_pull_at:
type:
- string
- 'null'
format: date-time
description: Most recent pull attempt, which may have failed; null if never.
last_error:
type:
- string
- 'null'
description: Error from the most recent attempt, or null. Immediate first pulls use short error codes.
pull_state:
type:
- string
- 'null'
enum:
- queued
- running
- done
- failed
- null
description: 'Additive immediate-first-pull state. `queued`/`running`: an immediate import is pending or in progress. `done`: the requested window was processed under Google''s existing row caps — not a complete Google total. `failed`: terminal permission/revocation outcome. null: unselected or never requested. A property pulled before the queue existed derives `done` from `pulled_through` when it has no queue state of its own.'
selected:
type: array
items:
type: string
description: The selected property names, in the same order as `properties`.
next:
type: string
description: A short hint about what happens next; wording is not part of the contract.
'400':
description: '`bad_request` for a malformed body; `unknown_property` when a name is not visible to this connection.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: '`unauthorized`, or `reconnect_required` when the stored connection has been revoked and must be reconnected before work can be queued.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: '`insufficient_scope` when the key lacks the `read` scope.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: '`not_connected`: no Google connection for this account.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
$ref: '#/components/responses/Unavailable'
/v1/gsc/daily:
get:
tags:
- Search Console
operationId: gsc_daily_rows
summary: Your Search Console rows, by day
description: 'Your own retained Search Console rows for one selected property, aggregated by `date` × `page` (or `date` × `page` × `query` with `dimensions=page,query,date`). Rows always carry `date`, never `day`. Read-only and additive: no Google request, no pull, no write. Every row is filtered to your account and the exact property. Aggregation uses the real page × query grain only (`page` and `query` non-empty, all-device, all-country): `clicks` and `impressions` are summed, `ctr` is `sum(clicks)/sum(impressions)` (0 with no impressions) and `position` is the impression-weighted `sum(position*impressions)/sum(impressions)` (null with no impressions) — CTRs and positions are never averaged unweighted. An empty `rows` array means no retained rows for the window, never zero-filled days. Coverage is honest, not complete: Google returns only its top rows per request and the pull stops after four pages per chunk, so anonymized queries and capped rows are absent. `pulled_through` is the last day actually pulled; `last_pulled_at` is the most recent recorded pull attempt for the property, which may itself have failed, so neither claims the window is complete. These are observed search metrics; they do not show that any link or placement caused a change. Read with a customer key (`read` scope) or a dashboard session; the operator token is refused, and any Authorization header (even empty or whitespace) is answered by key authentication rather than a session. Errors and successes are private and no-store, and private URLs, queries and rows are never logged.'
parameters:
- name: property
in: query
required: true
schema:
type: string
minLength: 1
maxLength: 500
description: The Search Console property to read, exactly as Google names it (e.g. `sc-domain:example.com`). It must be visible to your connection and selected for pulling, else 403 property_not_selected.
- name: from
in: query
required: true
schema:
type: string
format: date
description: Inclusive start day, `YYYY-MM-DD`.
- name: to
in: query
required: true
schema:
type: string
format: date
description: Inclusive end day, `YYYY-MM-DD`. `from` and `to` may be at most 180 days apart.
- name: page
in: query
schema:
type: array
maxItems: 100
items:
type: string
format: uri
maxLength: 4096
style: form
explode: true
description: Exact page URL to filter to. Repeat for several (at most 100); the exact spelling is preserved and duplicates are collapsed.
- name: dimensions
in: query
required: false
schema:
type: string
enum:
- page,date
- page,query,date
default: page,date
description: '`page,date` sums each page''s queries together; `page,query,date` keeps the query column.'
- name: cursor
in: query
required: false
schema:
type: string
maxLength: 1024
description: Opaque continuation cursor from `next_cursor`. It carries only the last group's day and an internal decimal id, is bound by hash to the account and the full filter set, and is only a position marker (never an authorization credential, and never a copy of a page URL or query). A cursor for another account, a changed filter set or a date outside the requested window is refused with 400 bad_cursor. Re-authorisation and property selection are re-checked on every request.
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
property:
type: string
from:
type: string
format: date
to:
type: string
format: date
dimensions:
type: array
items:
type: string
enum:
- page
- query
- date
last_pulled_at:
type:
- string
- 'null'
format: date-time
description: RFC 3339 timestamp of the most recent recorded pull attempt for this property, or null if never. It may record a failed attempt, so it does not prove the window is complete; it is not reported as zero. The raw database timestamp text is never exposed.
pulled_through:
type:
- string
- 'null'
format: date
description: Last day actually pulled for this property, or null.
count:
type: integer
description: Rows in this response (not the total matching rows).
rows:
type: array
maxItems: 50000
description: At most 50,000 rows. The 3,000,000-byte response cap may produce a shorter page with a continuation cursor.
items:
type: object
required:
- date
- page
- clicks
- impressions
- ctr
- position
properties:
date:
type: string
format: date
description: Google's stored day (YYYY-MM-DD), with no timezone conversion.
page:
type: string
description: Exact page URL, as stored.
query:
type: string
description: Present only in the page,query,date dimension.
clicks:
type: integer
impressions:
type: integer
ctr:
type: number
description: sum(clicks)/sum(impressions); 0 when there were no impressions.
position:
type:
- number
- 'null'
description: Impression-weighted average position; null when there were no impressions.
next_cursor:
type:
- string
- 'null'
description: Pass back as `cursor` for the next page. Null when the window is exhausted. Returned when either the row or serialized-byte bound was reached; the serialized body (envelope and cursor included) stays under the byte cap, and boundaries never skip a row. Changing source data between requests is not a fixed snapshot.
note:
type: string
description: 'Coverage and interpretation note: what the rows are not (a complete Google total) and that they show no causality.'
'400':
description: '`bad_date`, `range_too_long`, `bad_cursor`, `bad_request` (including unknown dimensions or an invalid page URL), `duplicate_parameter`, or `unknown_parameter`.'
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; `property_not_selected` when the property is missing, unselected or not visible to this connection.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: '`not_connected`: no Google connection for this account, or the connection was revoked.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: '`response_too_large` when a single aggregated row cannot fit the response byte cap (the row is never silently skipped).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
$ref: '#/components/responses/Unavailable'
components:
schemas:
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'
Unauthorized:
description: 'Missing, malformed or revoked Bearer credential: `{ error: "unauthorized" }`.'
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.'