{"openapi":"3.1.0","info":{"title":"Placement 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.\n\nAuthenticate 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":"Catalogue","description":"Search inventory, inspect a publisher, read market context."},{"name":"Planning","description":"Briefs in, costed and held baskets out."},{"name":"Orders","description":"Place, list, cancel and follow orders. Requires the `order` scope to spend."},{"name":"Content","description":"Articles for placements where the buyer supplies or commissions copy."},{"name":"Account","description":"Balance, spend limits, keys, briefs and automation."},{"name":"Checkout","description":"Card checkout for a basket, with or without an account."},{"name":"Comparison","description":"Read-only comparison of a basket captured elsewhere (Perch). No ordering, no writes."},{"name":"Research","description":"Target profiles, gap maps and keyword winners (DataForSEO-backed)."},{"name":"Search Console","description":"A customer's own retained Search Console rows. Read-only, never shared."},{"name":"Meta","description":"Service banner, health and field semantics."}],"components":{"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."}},"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`."}},"responses":{"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"}}}},"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"}}}},"NotFound":{"description":"Not found. Also returned for resources belonging to another customer, so an id is not confirmed as real.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Conflict":{"description":"State conflict; see the operation for the `error` codes.","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"}}}},"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"}}}}},"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},"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."},"PlacementType":{"type":"string","enum":["guest_post","niche_edit","link_insert","homepage_link","media_placement"]},"OrderStatus":{"type":"string","enum":["ordered","awaiting_content","content_revision_requested","awaiting_operator","submitted_to_vendor","delivered","cancelled","refunded","failed"]},"Verification":{"type":["string","null"],"enum":["ok","suspect","unverified",null],"description":"Our independent verdict on the claimed authority; no supplier publishes it. 'suspect' = high link authority with little or no organic search presence, which is what a link farm looks like — do not plan around suspect domains. 'ok' = measured, and search presence matches the rating. 'unverified' = not measured yet, which is not a pass."},"QualityReasons":{"type":"array","items":{"type":"string"},"description":"Which rules made verification read suspect: high_authority_no_traffic, high_authority_no_keywords, spam_score, subdomain_without_own_footprint, saturated_outbound. Empty when not suspect."},"Restricted":{"type":"array","items":{"type":"string"},"description":"gambling / adult / piracy when the site is one, from the domain name and a short topic list. Such rows appear only when asked for (include_restricted, or a topic/accepts naming the vertical)."},"Freshness":{"type":"object","description":"Prices are scrapes, not live quotes. Age travels with the data; stale after 48h.","properties":{"observed_at":{"type":"string","format":"date-time"},"age_hours":{"type":"number"},"stale":{"type":"boolean"},"stale_after_hours":{"type":"integer","const":48}}},"Plausibility":{"type":["object","null"],"description":"Where this price sits among comparable domains (same placement type, inventory mode and claimed-DR decade). flag 'low' = under the 10th percentile, which is bought authority more often than it is a bargain; 'high' = above the 90th. Null when the band had too few domains to describe.","properties":{"pct":{"type":"number","minimum":0,"maximum":100},"band":{"type":"string","examples":["guest_post named DR 50-59"]},"flag":{"type":"string","enum":["low","ok","high"]},"n":{"type":"integer","description":"Domains behind the distribution."}}},"PlacementScore":{"type":["object","null"],"description":"Placement Score v1: { score 0-100, version, confidence high|medium|low, components each 0-100 or null, capped }. Weights (authority 25 / visibility 25 / quality 30 / graph 10 / survival 10) are stated, not fitted; unmeasured components drop out and the rest re-weight; a suspect publisher never scores above 40. Derived from the fields beside it, so it can be checked. Does NOT include observed effectiveness, relevance to your brief, or supplier reliability. Quote the components, not just the number.","properties":{"score":{"type":"integer","minimum":0,"maximum":100},"version":{"type":"string"},"confidence":{"type":"string","enum":["high","medium","low"],"description":"Share of the weight that had a measurement behind it."},"components":{"type":"object","properties":{"authority":{"type":["number","null"]},"visibility":{"type":["number","null"]},"quality":{"type":["number","null"]},"graph":{"type":["number","null"]},"survival":{"type":["number","null"]}}},"capped":{"type":["string","null"],"enum":["suspect",null]}}},"MarketReference":{"type":["object","null"],"description":"The median ASK of comparable placements (same type, inventory mode and claimed-DR decade), not a clearing price — say 'market reference', never 'fair value'. own_asks is the spread of asks across suppliers for this exact domain and type, as counts and credits only. ask_history is null under 14 days of observations. Null on a thin band.","properties":{"reference_credits":{"type":"integer"},"method":{"type":"string","const":"band_median_v1"},"comparables_n":{"type":"integer"},"vs_reference_pct":{"type":"number","description":"This price against the reference, in percent; negative is cheaper."},"assessment":{"type":"string","enum":["under","at","over"],"description":"±15% band around the reference."},"own_asks":{"type":"object","properties":{"listings":{"type":"integer"},"suppliers":{"type":"integer"},"lowest":{"type":"integer"},"median":{"type":"integer"},"highest":{"type":"integer"}}},"ask_history":{"type":["object","null"],"properties":{"first_seen_at":{"type":"string","format":"date-time"},"days_observed":{"type":"integer"},"first_credits":{"type":"integer"},"change_pct":{"type":"number"}}}}},"SupplierReliability":{"type":"object","description":"How the seller behind the line has performed for us (fulfilment, on-time delivery, link survival, failures, refunds) over n submitted orders. Never a name. Within 5% of price the basket prefers the better band; 'unknown' (under five orders) is judged on price alone.","properties":{"band":{"type":"string","enum":["high","medium","low","unknown"]},"n":{"type":"integer"}}},"Survival":{"type":["object","null"],"description":"What happened to the placements WE bought on this publisher. Our crawler re-checks every delivered link at 0/7/30/60/90/180/365 days. Null under three orders.","properties":{"orders":{"type":"integer"},"checks":{"type":"integer"},"live_share":{"type":"number"},"live_90d_share":{"type":["number","null"]},"median_days_to_missing":{"type":["number","null"]},"dofollow_share":{"type":["number","null"]},"noindex_share":{"type":["number","null"]},"updated_at":{"type":"string","format":"date-time"}}},"Demand":{"type":["object","null"],"description":"Observed demand: distinct buyers who bought the domain in twelve months and the niches they bought for. Null under the three-buyer floor. Nothing about any one buyer is ever here.","properties":{"buyers_12m":{"type":"integer"},"purchases_12m":{"type":"integer"},"niches":{"type":"array","items":{"type":"object","properties":{"topic":{"type":"string"},"buyers":{"type":"integer"}}}}}},"LeaderLinks":{"type":["object","null"],"description":"Common Crawl web graph, root sites only (null on subdomains): how many of a topic's top-ranking sites the domain links to, keyed by topic.","additionalProperties":{"type":"object","properties":{"linked":{"type":"integer"},"of":{"type":"integer"}}}},"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."}}},"ContentOption":{"type":"object","description":"A writing tier purchasable with the placement. Tiers are normalised across every supplier: 'supplied' = you provide the copy; 'included' = writing is in the price; 'standard' = purchasable, AI-drafted and human-edited; 'native' = purchasable, native expert writer; 'papi_written' = Placement Papi drafts to the spec for a flat 1,500 credits charged with the order, buyer approves before it goes out.","properties":{"tier":{"type":"string","enum":["supplied","included","standard","native","papi_written"]},"vendor_label":{"type":["string","null"],"description":"Pass as content_tier for content_mode 'service'."},"price_credits":{"type":"integer","description":"Add-on cost. Credits. 1 credit = 1 US cent."},"word_count":{"type":["integer","null"]},"note":{"type":["string","null"]}}},"OrderRequirements":{"type":["object","null"],"description":"What you MUST send, what you MAY send, and what you can buy, normalised across suppliers. `required` lists the fields to include in POST /v1/orders. Read `confidence`: 'observed' and 'documented' are reliable; 'assumed' means the confirmation step may ask for something not listed.","properties":{"required":{"type":"array","items":{"type":"string"},"description":"Always includes target_url; anchor_text is absent only where the publisher writes the anchor."},"optional":{"type":"array","items":{"type":"string"}},"content":{"type":"object","properties":{"supplied_allowed":{"type":"boolean","description":"Whether you may write the article yourself."},"spec":{"$ref":"#/components/schemas/ContentSpec"},"options":{"type":"array","items":{"$ref":"#/components/schemas/ContentOption"}},"due_days":{"type":["integer","null"],"description":"Days a supplied article may take to arrive after ordering (14); null where the publisher writes."}}},"speed":{"type":"object","properties":{"standard_days":{"type":["integer","null"]},"expedited_days":{"type":["integer","null"]},"expedited_surcharge_bps":{"type":["integer","null"]}}},"confirmation_required":{"type":"boolean","description":"True when a human at the supplier confirms the line before it is placed; treat the order as pending rather than done."},"rules":{"type":"array","items":{"type":"string"},"description":"Placement rules the buyer must respect regardless of who writes the copy."},"confidence":{"type":"string","enum":["observed","documented","assumed"]}}},"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."}}}}}},"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}}},"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"}}}},"OfferMetrics":{"type":["object","null"],"description":"What we know about the domain, and who says so. Claimed and measured figures are never merged: where they disagree, the measured one is the one to trust. Null when nothing is known.","properties":{"dr":{"type":["number","null"],"description":"Supplier-claimed Ahrefs-style score, 0-100; the scale dr_min filters on. On a subdomain this is the PARENT's number."},"traffic":{"type":["number","null"],"description":"Verified traffic where measured, else the claim."},"source":{"type":"string","description":"Provenance label for dr/traffic. Measured figures are in the verified_* fields."},"verification":{"$ref":"#/components/schemas/Verification"},"quality_reasons":{"$ref":"#/components/schemas/QualityReasons"},"subdomain_of":{"type":["string","null"],"description":"Parent domain when the host is a subdomain (blog.example.com, anything.blogspot.com). A subdomain is listed at its parent's DR and inherits none of it: read verified_dr, verified_referring_domains and verified_traffic, which are this host's own."},"restricted":{"$ref":"#/components/schemas/Restricted"},"outbound_external":{"type":["integer","null"],"description":"Outside domains linked to (Common Crawl; root sites only)."},"inbound_domains":{"type":["integer","null"]},"cc_pagerank":{"type":["number","null"]},"leader_links":{"$ref":"#/components/schemas/LeaderLinks"},"survival":{"$ref":"#/components/schemas/Survival"},"placement_score":{"$ref":"#/components/schemas/PlacementScore"},"verified_organic_keywords":{"type":["integer","null"],"description":"OUR independent measurement."},"verified_spam_score":{"type":["number","null"]},"as_of":{"type":["string","null"],"format":"date-time"},"verified_traffic":{"type":["number","null"],"description":"Independently measured monthly traffic."},"verified_dr":{"type":["number","null"],"description":"DataForSEO backlinks rank, 0-1000. Deliberately NOT rescaled to look like a DR."},"verified_referring_domains":{"type":["integer","null"]}}},"Offer":{"type":"object","description":"One catalogue row from GET /v1/offers. 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":"Public offer id. Its prefix is the opaque supplier token accepted by the `supplier` filter."},"placement_type":{"$ref":"#/components/schemas/PlacementType"},"inventory_mode":{"$ref":"#/components/schemas/InventoryMode"},"site_id":{"type":["string","null"],"description":"Canonical domain. Only 'named' inventory carries one."},"price":{"type":"object","description":"The BUYER price: the figure debited at order time, so a basket costed from this endpoint will not move at checkout.","properties":{"credits":{"type":"integer","description":"Credits. 1 credit = 1 US cent."},"usd_minor":{"type":"integer","description":"The same number in cents, so you never have to convert."},"currency":{"type":"string","const":"USD"},"as_of":{"type":"string","format":"date-time"}}},"plausibility":{"$ref":"#/components/schemas/Plausibility"},"demand":{"$ref":"#/components/schemas/Demand"},"bulk_breaks":{"description":"Supplier volume breaks, as recorded."},"link_attrs":{"description":"Link attributes as recorded (e.g. links_per_placement, dofollow)."},"content":{"description":"Raw content facts on the listing (e.g. included, word_count_min, author). Prefer order_requirements.content."},"guarantees":{"description":"Vendor guarantees on blind/pre_approval tiers (e.g. dr_min, referring_domains_min, traffic_min)."},"metrics":{"$ref":"#/components/schemas/OfferMetrics"},"turnaround_days_est":{"type":["integer","null"]},"incentive":{"type":["object","null"],"description":"A discount for ordering before the next batch closes. Rewards WHEN you order, never how much; spend caps still apply.","properties":{"discount_pct":{"type":"number"},"price_with_incentive_credits":{"type":"integer"},"expires_at":{"type":"string","format":"date-time"},"reason":{"type":"string"}}},"order_requirements":{"$ref":"#/components/schemas/OrderRequirements"},"niches_allowed":{"type":"array","items":{"type":"string"},"description":"The supplier's own raw labels. Prefer topics."},"topics":{"type":"array","items":{"type":"string"},"description":"Harmonised subjects."},"accepts":{"type":"array","items":{"type":"string"},"description":"Empty means UNKNOWN, not 'accepts nothing'."},"geo":{"description":"Country, returned per offer; there is no geo filter on GET /v1/offers, filter client-side."},"languages":{},"publisher_notes":{"type":["string","null"]},"observed_at":{"type":"string","format":"date-time"},"age_hours":{"type":"number"},"stale":{"type":"boolean"},"stale_after_hours":{"type":"integer"}}},"QuoteAlternative":{"type":"object","description":"Another domain that could take this line's place, HELD at the price shown. Order it with the same quote_id and it is charged that price; the primary it replaces is simply not ordered.","properties":{"offer_id":{"type":"string"},"site_id":{"type":["string","null"]},"placement_type":{"$ref":"#/components/schemas/PlacementType"},"price_credits":{"type":"integer"},"dr":{"type":["number","null"]},"verification":{"$ref":"#/components/schemas/Verification"},"plausibility_pct":{"type":["number","null"]}}},"QuoteLine":{"type":"object","description":"One placement in a held basket (POST /v1/quote, POST /v1/targets/gap, GET /v1/quotes/{quote_id}). Fields are added, never renamed.","properties":{"offer_id":{"type":"string"},"site_id":{"type":["string","null"],"description":"Null on blind inventory."},"inventory_mode":{"$ref":"#/components/schemas/InventoryMode"},"placement_type":{"$ref":"#/components/schemas/PlacementType"},"price_credits":{"type":"integer","description":"Held price. Credits. 1 credit = 1 US cent."},"dr":{"type":["number","null"],"description":"The vendor's claim. On a subdomain, the parent's number."},"traffic":{"type":["number","null"]},"verification":{"$ref":"#/components/schemas/Verification"},"verified_dr":{"type":["number","null"],"description":"DataForSEO backlinks rank, 0-1000; corroborates, does not replace dr."},"verified_traffic":{"type":["number","null"]},"organic_keywords":{"type":["integer","null"]},"referring_domains":{"type":["integer","null"]},"spam_score":{"type":["number","null"]},"subdomain_of":{"type":["string","null"]},"quality_reasons":{"$ref":"#/components/schemas/QualityReasons"},"restricted":{"$ref":"#/components/schemas/Restricted"},"outbound_external":{"type":["integer","null"]},"inbound_domains":{"type":["integer","null"]},"cc_pagerank":{"type":["number","null"]},"leader_links":{"$ref":"#/components/schemas/LeaderLinks"},"survival":{"$ref":"#/components/schemas/Survival"},"supplier_reliability":{"$ref":"#/components/schemas/SupplierReliability"},"placement_score":{"$ref":"#/components/schemas/PlacementScore"},"market_reference":{"$ref":"#/components/schemas/MarketReference"},"plausibility":{"$ref":"#/components/schemas/Plausibility"},"demand":{"$ref":"#/components/schemas/Demand"},"why":{"type":"array","items":{"type":"string"},"description":"Factual sentences behind the pick, derived from the fields on this line and the brief — cite them. Prose; do not parse it."},"intelligence":{"type":"object","description":"Present only when the deployment has placement intelligence configured and this placement was actually judged; absent means not judged, which is not the same as judged and rejected. An advisory semantic judgment. In a `recommended` basket running in configured `rank` mode a rank-usable judgment can reorder a line within the explicit demand/leader priorities; it can never exclude a placement, an alternative or a publisher, and missing, invalid, failed or below-threshold evidence leaves the deterministic result — and every price — unchanged. The service judges a sampled publisher page resolved by site id, not the offered placement itself, so a below-threshold reading is not evidence against buying this slot. `topical_fit` and `confidence` are on the service's own scale — not a probability of SEO impact. No provider payload, evidence text or customer id is ever included.","properties":{"status":{"type":"string","description":"computed | cached when a judgment exists; disabled | insufficient_evidence | no_evidence | invalid_evidence | unavailable | budget_exhausted | busy | invalid_response | timeout otherwise."},"rank_usable":{"type":"boolean","description":"True only in rank mode when the service's own policy called the judgment usable. In a `recommended` basket a rank-usable judgment is what can reorder the placement; no judgment, usable or not, removes one."},"topical_fit":{"type":["number","null"],"description":"The judged fit to the brief's recognized topics, on the service's own scale; null when nothing usable was produced."},"confidence":{"type":["number","null"]},"editorial_format":{"type":["string","null"]},"policy_version":{"type":["string","null"]}}},"relevance":{"description":"Topic coverage of the brief, or null when the brief named no topics."},"alternatives":{"type":"array","maxItems":2,"items":{"$ref":"#/components/schemas/QuoteAlternative"}},"turnaround_days":{"type":["integer","null"]},"topics":{"type":"array","items":{"type":"string"}},"guarantees":{},"order_requirements":{"$ref":"#/components/schemas/OrderRequirements"}}},"Quote":{"type":"object","description":"A costed, HELD basket. Nothing is reserved and nothing is charged until you order, so quoting is free. Prices are held for ttl_seconds (4 hours).","properties":{"quote_id":{"type":"string"},"quote_token":{"type":"string","description":"Bearer for GET /v1/quotes/{quote_id}?token=… so an approval link can show the plan without a key. Dies with the quote."},"view_url":{"type":"string","format":"uri"},"target_url":{"type":["string","null"]},"expires_at":{"type":"string","format":"date-time"},"ttl_seconds":{"type":"integer","const":14400},"binding":{"type":"boolean","const":true,"description":"These prices are ours to honour until they lapse, even if the catalogue moves."},"strategy":{"type":"string","enum":["cheapest","fastest","safest","recommended"],"description":"The strategy the basket was assembled with, echoed. Every strategy honours the same filters, quality gates, footprint cap and budget. `cheapest`, `fastest` and `safest` change ordering only; `recommended` in a deployment whose semantic layer runs in `rank` mode can also reorder placements by a rank-usable judgment, within the explicit demand/leader priorities. No strategy and no judgment removes a placement or an alternative."},"requested":{"type":"integer"},"filled":{"type":"integer"},"eligible":{"type":"integer","description":"Distinct options this brief can buy, before the cap: collapsed domain+placement slots the sourcing read could quote after the filters and, when budgeted, individual affordability. A semantic annotation never removes a slot, so this is the count the deterministic engine would have produced; `candidate_coverage` holds the pre-semantic counts."},"candidate_coverage":{"type":"object","description":"How much of the eligible catalogue the candidate query looked at, from the same snapshot as the lines. Discovery and sourcing counts, taken before any semantic policy runs: a semantic annotation changes neither `eligible` nor these counts, because it removes nothing. `eligible` above counts collapsed domain+placement slots; this counts candidate OFFERS before the collapse, so it is the field that shows whether the 4,000-offer retrieval cap bound the result. eligible_offers equal to retrieved_offers means every matching offer was ranked; a larger eligible_offers with truncated true means the basket was chosen from the first 4,000 in the brief's requested priority (demand, leaders, then fastest/safest/cheapest/recommended, with raw price and offer id as the deterministic tiebreak). Raw-price retrieval is an approximation of the final buyer-credit sort, and budget fill can end a basket before the cap matters, so truncated false is not a promise that the basket is globally optimal. `sourced_offers` and the fields beside it describe the second, bound read: the sourcing expansion re-reads every eligible seller of each chosen placement, so the winner per placement is chosen from the whole supplier set rather than from the retrieval cut, and a placement is either sourced whole or not at all. Aggregate counts only.","properties":{"eligible_offers":{"type":"integer","description":"Offers matching eligibility and, when budgeted, individual affordability, before the 4,000-offer cap and before any semantic policy. Exclusion counts retain their original filter-only basis."},"retrieved_offers":{"type":"integer","description":"Offers the candidate query returned. Equals eligible_offers unless truncated."},"limit":{"type":"integer","description":"The retrieval cap, in offers. 4000."},"truncated":{"type":"boolean","description":"eligible_offers exceeded the cap, so selection saw a subset of what matched."},"sourced_offers":{"type":"integer","description":"Offers the sourcing expansion returned for the chosen placements. Equal to retrieved_offers when the retrieved rows already carried every seller of every placement; larger when the cut had kept a dearer seller and left a cheaper one outside it."},"sourcing_limit":{"type":"integer","description":"The sourcing cap, in offers. 40000."},"sourcing_truncated":{"type":"boolean","description":"The expansion did not answer for every chosen placement, so fewer placements were quotable than retrieval found. Never means a placement was priced from a partial supplier set."},"dropped_slots":{"type":"integer","description":"Chosen placements the expansion did not return, and so did not quote. Placements, not offers; zero unless sourcing_truncated."}}},"decision":{"type":["object","null"],"description":"Aggregate semantic-annotation coverage for this basket. Present only when the request carried customer context, at least one placement was affordable, and a semantic service was reachable — absent otherwise, which is not the same as 'judged and found nothing'. `mode` is the service's mode (off | shadow | rank) or `unavailable` when it was configured for the deployment but could not be built or failed; `status` is the service's own coverage status, or `unavailable` for that same failure. `rank` is the only mode that can change a `recommended` basket, and only in that strategy: a rank-usable annotation may reorder it within the explicit demand/leader priorities, and no judgment — below threshold, unjudged, missing, invalid or failed — removes anything. `shadow` annotates only, and `cheapest`, `fastest` and `safest` are unchanged. The service judges a sampled publisher page resolved by site id, not the offered placement, so no judgment is a verdict on whether to buy a slot. Topical fit is a semantic judgment on the service's own scale, NOT a probability of SEO impact, and it never affects price. Aggregate counts only — no customer ids, provider state or evidence text.","properties":{"mode":{"type":"string","enum":["off","shadow","rank","unavailable"]},"status":{"type":"string","enum":["ok","degraded","insufficient_evidence","invalid_config","unavailable"]},"coverage":{"type":"object","description":"How much of the semantic layer's candidate input was judged for this basket. `total_candidates` is the raw input length, `eligible_candidates` every valid eligible unique candidate id before the page service's own cap, and `selected_candidates` the BOUNDED page-semantic shortlist the service actually selected after its site-diverse fan-out and `maxCandidates` cap — the three are separately meaningful and only ever get smaller in that order, so on a wide brief `eligible_candidates` is larger than the bounded shortlist. Every selected candidate ends in exactly one of `evaluated`, `missing_evidence`, `invalid_evidence` or `runtime_failed`, so those four always sum to `selected_candidates`; a missing, failed or refused reading is never counted as a judgment, and an unselected eligible candidate is counted by none of them. Aggregate counts only.","properties":{"total_candidates":{"type":"integer","description":"The raw candidate input length, before validation, deduplication or any cap."},"eligible_candidates":{"type":"integer","description":"Every valid eligible unique candidate id, before the page service's own cap. This is the FULL eligible input set, not the bounded semantic shortlist, and it can exceed `selected_candidates`."},"selected_candidates":{"type":"integer","description":"The bounded page-semantic shortlist: the valid eligible unique candidates the service actually selected after its site-diverse fan-out and `maxCandidates` cap. Additive, and the only bounded shortlist denominator: `evaluated + missing_evidence + invalid_evidence + runtime_failed` always equals it."},"evaluated":{"type":"integer"},"policy_usable":{"type":"integer"},"missing_evidence":{"type":"integer"},"invalid_evidence":{"type":"integer"},"runtime_failed":{"type":"integer"}}},"identity_coverage":{"type":"object","description":"Additive, aggregate-only publisher-identity routing coverage. It answers a second question beside `coverage`, which keeps its own counts and status untouched: when this basket's page layer had no sampled page evidence for a candidate, did the durable publisher identity still settle a requested topic? `status` is `supported` when the routing preference was in force (a `recommended` brief whose confirmed mode was `rank`) and its one bounded identity read answered — which includes a successful empty read, reported as `supported` with zero counts, never as a failure; `unavailable` when that read did not answer or the page service failed so no shortlist exists; and `not_applicable` when no routing preference applied at all (no configured preference, another strategy, no recognized requested topic, or `off`/`shadow`, which annotate only). Counts are `missing_evidence` candidates only: a candidate counts here only when its own page annotation status was exactly `no_evidence` AND its site held a fresh supported primary-identity match for a requested topic, so refused or failed readings (`invalid_evidence`, `unavailable`, `timeout`, runtime failures) are never covered and `coverage.missing_evidence` is never reduced. Every count is over the page service's own bounded SELECTED shortlist — the bounded page-semantic set the service chose out of the full eligible input, so `shortlist_candidates` equals `coverage.selected_candidates` and never `coverage.eligible_candidates` — and `shortlist_candidates` is the annotated part of it — the candidates that came back with an explicit annotation, `no_evidence` included — so it is not a count of candidates the semantics evaluated. Aggregate counts and a fixed status only: no site ids, topics, facets, evidence ids, excerpts, provider payload or confidence values.","properties":{"status":{"type":"string","enum":["supported","unavailable","not_applicable"]},"shortlist_candidates":{"type":"integer","description":"The page service's own bounded shortlist of SELECTED candidates — the candidates it selected and returned an explicit annotation for, including `no_evidence` (the page policy's \"not measured\" reading, not a judgment). For a service that annotates exactly what it selected this is the same set `coverage.selected_candidates` counts, and never `coverage.eligible_candidates`, which is the larger full eligible input before the service's own cap. Not a count of candidates the semantics evaluated; every other count here is a subset of this one."},"missing_page_evidence":{"type":"integer","description":"That subset whose page annotation status was exactly `no_evidence`. Same number as `coverage.missing_evidence` when the aggregate is `supported`."},"supported_identity_missing_page_evidence":{"type":"integer","description":"The subset of `missing_page_evidence` whose site also held a fresh supported primary-identity match for one of the brief's requested topics. A routing coverage gate passes a degraded page decision only when this equals `missing_evidence`."}}},"publisher_context_coverage":{"type":"object","description":"Additive, aggregate-only projected publisher-context coverage. It answers a second, separate question beside `coverage` and `identity_coverage`, both of which keep their own counts and statuses untouched: when this basket's page layer had no sampled page evidence for a candidate, did the publisher context the request's OWN route projection supplied cover it? `status` is `supported` only for the frame in which those projected facts were in force — a `recommended` brief whose confirmed mode was `rank`, whose route attempt answered from a structurally valid `state_usable` payload, with the identity composition present and a recognized requested topic; `unavailable` when that frame was in force and the page service answered but the route projection did not supply the context facts (the attempt was not state-usable, so the exact predicates ran); and `not_applicable` when no such frame applied (another strategy, `off`/`shadow`, no identity composition, or no recognized requested topic). Counts describe one annotated shortlist — the page service's own bounded selected shortlist, the set `coverage.selected_candidates` counts, never the larger full eligible input `coverage.eligible_candidates` — and may overlap; they are never summed into a score. `missing_page_evidence` is the page policy's own not-measured reading, and `context_supported_missing_page_evidence` is the SUBSET of `missing_page_evidence` whose candidate site is `context_classified` OR qualifies as exceptional independent authority — the covered part of the page gap, never the page gap itself. A positive primary or prominent match is NOT required for coverage: a classified non-match remains classified, and a publisher whose independent authority clears this catalogue's measured cut-offs covers a missing page reading without any category designation. A refused or failed reading (`invalid_evidence`, `runtime_failed`, `unavailable`, `timeout`) is never covered, a malformed or refusable route attempt is never covered, and `coverage.missing_evidence` is never reduced. Routing completeness requires this covered-subset count to equal `missing_page_evidence`, so a page gap that is only partly covered is not routing-complete. Aggregate counts and a fixed status only: no site ids, topics, facets, evidence ids, excerpts, URLs, provider payload or confidence values.","properties":{"status":{"type":"string","enum":["supported","unavailable","not_applicable"]},"shortlist_candidates":{"type":"integer","description":"The page service's own bounded shortlist of SELECTED candidates — the candidates it selected and returned an explicit annotation for, including `no_evidence` (the page policy's \"not measured\" reading, not a judgment). The routing gate requires it to equal `coverage.selected_candidates`, and it is never `coverage.eligible_candidates`, which is the larger full eligible input before the service's own cap. Not a count of candidates the semantics evaluated; every other count here is a subset of this one."},"context_classified":{"type":"integer","description":"That subset whose site the projection reports as classified at all. Coverage only: it is not a positive topic match, a preference, a score or an eligibility term."},"primary_topic_matches":{"type":"integer","description":"That subset whose site held a supported requested primary-identity match. Reported beside the other bases and never required for coverage; counts may overlap."},"prominent_profile_matches":{"type":"integer","description":"That subset whose site held a requested PROMINENT sampled profile topic — a sampled publisher URL distribution over publisher-supplied sitemap metadata, not a primary identity. Reported, never required for coverage; counts may overlap."},"exceptional_authority":{"type":"integer","description":"That subset whose root domain the existing independent-authority reading qualifies (measured quality `ok`, fresh independent traffic and Common Crawl PageRank at or above this catalogue's measured cut-offs, nothing from a seller's own claims). Reach is not editorial relevance and claims no SEO impact."},"missing_page_evidence":{"type":"integer","description":"That subset whose page annotation status was exactly `no_evidence`. Same number as `coverage.missing_evidence` when the aggregate is `supported`."},"context_supported_missing_page_evidence":{"type":"integer","description":"The SUBSET of `missing_page_evidence` whose candidate site is `context_classified` OR qualifies as exceptional independent authority — the covered part of the page gap, never the page gap itself. Classification is coverage, not a requested-topic match, so a positive primary or prominent match is not required for a candidate to be counted here, and a challenged reading (`invalid_evidence`, `runtime_failed`, `unavailable`, `timeout`) is never covered. Routing completeness requires this covered-subset count to equal `missing_page_evidence`: a page gap covered only in part is not routing-complete."}}}}},"excluded":{"type":"object","additionalProperties":{"type":"integer"},"description":"Candidate offers each quality gate removed, keyed by reason (e.g. suspect, restricted, already_linking, bought_for_target). Pre-collapse counts; do not sum with eligible. Every key is a SQL gate: a semantic annotation adds none, because it removes no placement, and the evidence behind a below-threshold judgment is a sampled publisher page rather than the placement actually on offer."},"total_credits":{"type":"integer","description":"Credits. 1 credit = 1 US cent."},"at_median_supplier_credits":{"type":"integer","description":"The same basket priced at the median supplier for each domain."},"saving_vs_median_credits":{"type":"integer"},"funding":{"type":["object","null"],"properties":{"balance_credits":{"type":"integer"},"sufficient":{"type":"boolean"},"shortfall_credits":{"type":"integer"}}},"lines":{"type":"array","items":{"$ref":"#/components/schemas/QuoteLine"}},"profile_hint":{"type":"object","description":"Only when exclude_domains_linking_to was given: the target's profile bands beside the basket's.","properties":{"target_domain":{"type":"string"},"referring_domains_fetched":{"type":"integer"},"profile_rank_bands_share":{"type":"object"},"profile_rank_bands_basis":{"type":"string"},"carried_referring_domains":{"type":"integer"},"profile_dr_bands_share":{"type":"object"},"profile_dr_bands_basis":{"type":"string"},"profile_heaviest_dr_band":{},"basket_dr_bands":{"type":"object"},"dr_band_suggested":{}}},"next":{"type":"object","properties":{"order":{"type":"string"},"note":{"type":"string"}}}}},"Brief":{"type":"object","description":"A plan_links brief. Unknown fields are refused (400 unknown_field), not ignored: a quote built from a partly understood brief would name domains you did not choose.","required":["links"],"additionalProperties":false,"properties":{"links":{"type":"integer","minimum":1,"maximum":100,"description":"How many placements to plan."},"strategy":{"type":"string","enum":["cheapest","fastest","safest","recommended"],"default":"cheapest","description":"Every strategy honours the same filters, quality gates, footprint cap and budget; cheapest is the default. fastest orders by turnaround, and safest prefers independently measured domains with real search traffic — those three change ordering only. recommended is additive and quality-first: measured-and-passed publishers before unmeasured ones, then the existing placement_score descending, with price (and offer id) only as deterministic ties and budget feasibility. In a deployment whose semantic layer runs in `rank` mode, recommended also reorders placements by a rank-usable judgment within the explicit demand/leader priorities; it omits nothing, and missing, failed or below-threshold evidence falls back to this same deterministic result and the same price. Its retrieval cut is a bounded quality proxy, not an exact global score ranking."},"budget_credits":{"type":"integer","minimum":1,"description":"Ceiling for the whole basket, in credits."},"placement_type":{"type":"string","enum":["guest_post","niche_edit","link_insert","homepage_link","media_placement"]},"inventory_mode":{"$ref":"#/components/schemas/InventoryMode"},"dr_min":{"type":"number","minimum":0,"maximum":100,"description":"Minimum DR, 0-100. Matches the site's claimed DR, or the vendor's guaranteed minimum on blind inventory. Never the measured verified_dr, which is on a 0-1000 scale."},"rd_min":{"type":"number","minimum":0,"description":"Minimum guaranteed Majestic referring domains. Only blind tiers sold on RD carry this guarantee; the RD and DR scales are never translated into each other."},"traffic_min":{"type":"number","minimum":0,"description":"Minimum monthly traffic: measured where known, else claimed, else the blind guarantee floor."},"price_max":{"type":"integer","minimum":1,"description":"Maximum price per placement, USD minor units (cents)."},"price_min":{"type":"integer","minimum":1,"description":"Price floor, USD minor units. A quality filter, not a budget one: a price far below the going rate for the claimed DR is evidence the DR was bought."},"verified_only":{"type":"boolean","default":false,"description":"Only domains measured independently AND passed. Stricter than excluding suspect domains, because it also drops the unmeasured."},"include_restricted":{"type":"boolean","default":false,"description":"Gambling, adult and piracy sites are excluded unless topic/accepts names the vertical or this is true."},"plausibility_min_pct":{"type":"number","minimum":0,"maximum":100,"description":"Keep only placements at or above this percentile of their own price band (placement type x inventory mode x claimed-DR decade). The measured form of price_min."},"supplier":{"type":"string","maxLength":64,"description":"Opaque supplier token taken from the prefix of any offer_id. Filters to one source without naming it. Real supplier names are not accepted and match nothing."},"topic":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Harmonised subject (e.g. finance, travel, health, technology). Repeatable; repeats are OR-ed."},"accepts":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Restricted vertical the placement must accept (crypto, gambling, cbd, vape, adult, dating, pharma, forex). Repeatable; repeats are AND-ed. An empty accepts array on an offer means UNKNOWN, not 'accepts nothing'."},"dr_max":{"type":"number","minimum":0,"maximum":100},"geo":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"languages":{"oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"include_suspect":{"type":"boolean","default":false,"description":"Suspect domains are excluded unless asked for."},"prefer_demand":{"type":"boolean","default":false,"description":"Order eligible placements by observed demand (distinct buyers in twelve months) before price."},"prefer_leaders":{"type":"boolean","default":false,"description":"Order eligible placements by leader_links for the brief's topic before price. Give a topic so the right leaders count."},"exclude_domains_bought_for":{"type":"string","maxLength":2048,"description":"A target URL or domain. Domains this account already bought for it are left out (excluded.bought_for_target)."},"exclude_domains_linking_to":{"type":"string","maxLength":2048,"description":"A target URL or domain. Every domain in its backlink profile is left out (excluded.already_linking) and the response carries profile_hint. Fetches the profile if not cached (counts against the daily profile budget)."},"target_url":{"type":"string","format":"uri","maxLength":2048,"description":"The page this plan is for. Remembered per target (campaign memory); the weekly brief and standing orders re-run it."},"max_per_domain":{"type":"integer","minimum":1,"maximum":10,"default":1,"description":"Ten links from one site is one relationship."}}},"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."}}},"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"}}}}}},"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"}}},"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"}}}}},"StandingOrder":{"type":"object","description":"A plan that re-runs on a cadence. 'approve' mode emails a held quote with an approval link each run. 'auto' places only when the account owner has enabled auto placement from the dashboard (POST /v1/customers/me/auto, session only) and the caps and per-run budget clear; otherwise it emails an approval link and says why.","properties":{"standing_order_id":{"type":"string"},"target_url":{"type":"string"},"brief":{"type":"object","description":"The plan_links brief to re-run; links and budget are overridden by links_per_run and budget_credits_per_run."},"cadence":{"type":"string","enum":["weekly","biweekly","monthly"]},"links_per_run":{"type":"integer"},"budget_credits_per_run":{"type":"integer"},"mode":{"type":"string","enum":["approve","auto"]},"anchor_plan":{"oneOf":[{"$ref":"#/components/schemas/AnchorPlan"},{"type":"null"}]},"if_no_content":{"type":["string","null"],"enum":["convert_to_service","cancel",null]},"content_mode":{"type":["string","null"],"enum":["publisher","service","supplied","papi_written",null]},"status":{"type":"string","enum":["active","paused"]},"next_run_at":{"type":["string","null"]},"last_run_at":{"type":["string","null"]},"last_quote_id":{"type":["string","null"]},"last_outcome":{"type":["string","null"]},"last_outcome_detail":{"type":["string","null"]},"created_at":{"type":"string"}}},"SiteListing":{"type":"object","properties":{"offer_id":{"type":"string"},"supplier":{"type":"string","description":"A per-response ordinal (A, B, C…) so you can see that listings come from different suppliers, without identifying them."},"placement_type":{"$ref":"#/components/schemas/PlacementType"},"price":{"type":"object","properties":{"credits":{"type":["integer","null"]},"usd_minor":{"type":["integer","null"]},"currency":{"type":"string","const":"USD"}}},"comparable":{"type":"boolean","description":"False when the listing has no USD-normalised price."},"market_reference":{"$ref":"#/components/schemas/MarketReference"},"observed_at":{"type":"string"},"age_hours":{"type":"number"},"stale":{"type":"boolean"},"stale_after_hours":{"type":"integer"}}},"Site":{"type":"object","description":"The one place everything about a publisher is assembled. Use it for compare_domain and any 'tell me about this site' question rather than stitching offers together.","properties":{"configured":{"type":"boolean"},"found":{"type":"boolean","const":true},"site":{"type":"object","properties":{"site_id":{"type":"string"},"domain":{"type":"string"},"domain_hidden":{"type":"boolean"},"categories":{"type":"array","items":{"type":"string"}},"language":{"type":["string","null"]},"country":{"type":["string","null"]},"subdomain_of":{"type":["string","null"]}}},"verification":{"type":"object","properties":{"flag":{"$ref":"#/components/schemas/Verification"},"reasons":{"$ref":"#/components/schemas/QualityReasons"}}},"restricted":{"$ref":"#/components/schemas/Restricted"},"graph":{"type":"object","description":"Common Crawl web graph, root sites only (null fields on subdomains).","properties":{"outbound_external":{"type":["integer","null"]},"inbound_domains":{"type":["integer","null"]},"cc_pagerank":{"type":["number","null"]},"cc_harmonic":{"type":["number","null"]},"leader_links":{"$ref":"#/components/schemas/LeaderLinks"}}},"survival":{"$ref":"#/components/schemas/Survival"},"link_history":{"type":["object","null"],"description":"The decay curve behind survival: our placements checked at each checkpoint and how many were live then. Same three-order floor.","properties":{"orders":{"type":"integer"},"by_checkpoint":{"type":"array","items":{"type":"object","properties":{"checkpoint":{"type":"string"},"checked":{"type":"integer"},"live":{"type":"integer"}}}},"first_check_at":{"type":"string"},"last_check_at":{"type":"string"}}},"outcomes":{"type":["object","null"],"description":"Observed after placements on this publisher across every buyer; null under three distinct buyers. Observed, never caused."},"placement_score":{"$ref":"#/components/schemas/PlacementScore"},"demand":{"$ref":"#/components/schemas/Demand"},"supplier_count":{"type":"integer","description":"Distinct suppliers carrying the domain — why a price spread exists — without saying who."},"spread_usd_minor":{"type":["integer","null"]},"listings":{"type":"array","items":{"$ref":"#/components/schemas/SiteListing"},"description":"Cheapest first."},"metrics":{"type":"array","description":"Vendor claims and independent readings side by side, never merged.","items":{"type":"object","properties":{"source":{"type":"string"},"rank":{"type":["number","null"]},"traffic":{"type":["number","null"]},"referring_domains":{"type":["integer","null"]},"spam_score":{"type":["number","null"]},"as_of":{"type":["string","null"]},"fetched_at":{"type":"string"}}}}}},"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."}}},"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."}}},"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."}}},"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."}}},"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."}}}}},"paths":{"/v1":{"get":{"tags":["Meta"],"operationId":"service_banner","security":[],"summary":"Service banner","description":"Unauthenticated. Confirms the service is up and points at the authenticated endpoints and the MCP transport. Carries no inventory or pricing.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"service":{"type":"string"},"version":{"type":"string"},"docs":{"type":"string"},"schema":{"type":"string"},"mcp":{"type":"object"},"endpoints":{"type":"array","items":{"type":"string"}}}}}}}}}},"/v1/health":{"get":{"tags":["Meta"],"operationId":"health","summary":"Catalogue coverage and freshness","description":"Check this before trusting prices. Customers see coverage counts and how many integrated suppliers' latest snapshot is stale, with the supplier roster itself withheld. `stale_after_hours` is the snapshot-recency threshold behind `suppliers_stale` and `all_fresh` — scrape recency for coverage, not the price judgement. Whether a specific price is current is measured per supplier: each catalogue price is labelled fresh, aged or stale against that supplier's own derived drift window, and an aged price is still quoted and orderable at its observed date (see /v1/offers and quote lines), so price freshness is a measured, per-supplier judgement rather than one fixed cutoff.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"configured":{"type":"boolean"},"stale_after_hours":{"type":"integer","description":"Snapshot age at which a supplier counts stale for `suppliers_stale` / `all_fresh`. Coverage recency only; each price's own fresh/aged/stale label is decided per supplier, not by this value."},"all_fresh":{"type":"boolean","description":"True when at least one supplier is integrated and none counts stale."},"coverage":{"type":"object","properties":{"suppliers_integrated":{"type":"integer"},"suppliers_stale":{"type":"integer"},"total_offers":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/schema":{"get":{"tags":["Meta"],"operationId":"field_semantics","summary":"Field semantics for agents","description":"Hand-authored, machine-readable description of what the fields MEAN (inventory modes, verification, pricing, anchor distribution, rate limits), for a model-driven client to self-configure against. Not a type schema; this document is.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"string"},"description":{"type":"string"},"critical_semantics":{"type":"object"},"mcp":{"type":"object"},"endpoints":{"type":"object"},"placement_types":{"type":"array","items":{"type":"string"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/offers":{"get":{"tags":["Catalogue"],"operationId":"search_placements","summary":"Search inventory","description":"Filtered catalogue rows, cheapest first, one per offer (a domain carried by several suppliers appears several times; POST /v1/quote collapses that). Unknown parameters are refused with 400 unknown_parameter. Rows count against the hourly and monthly read allowances; there is no geo filter — filter on the returned `geo` client-side.","parameters":[{"name":"placement_type","in":"query","required":false,"schema":{"type":"string","enum":["guest_post","niche_edit","link_insert","homepage_link","media_placement"]},"description":"Placement type."},{"name":"inventory_mode","in":"query","required":false,"schema":{"$ref":"#/components/schemas/InventoryMode"},"description":"named | pre_approval | blind."},{"name":"dr_min","in":"query","required":false,"schema":{"type":"number","minimum":0,"maximum":100},"description":"Minimum DR, 0-100. Matches the site's claimed DR, or the vendor's guaranteed minimum on blind inventory. Never the measured verified_dr, which is on a 0-1000 scale."},{"name":"rd_min","in":"query","required":false,"schema":{"type":"number","minimum":0},"description":"Minimum guaranteed Majestic referring domains. Only blind tiers sold on RD carry this guarantee; the RD and DR scales are never translated into each other."},{"name":"traffic_min","in":"query","required":false,"schema":{"type":"number","minimum":0},"description":"Minimum monthly traffic: measured where known, else claimed, else the blind guarantee floor."},{"name":"price_max","in":"query","required":false,"schema":{"type":"integer","minimum":1},"description":"Maximum price per placement, USD minor units (cents)."},{"name":"price_min","in":"query","required":false,"schema":{"type":"integer","minimum":1},"description":"Price floor, USD minor units. A quality filter, not a budget one: a price far below the going rate for the claimed DR is evidence the DR was bought."},{"name":"verified_only","in":"query","required":false,"schema":{"type":"string","enum":["true","false","1","0"]},"description":"Only domains measured independently AND passed. Stricter than excluding suspect domains, because it also drops the unmeasured."},{"name":"include_restricted","in":"query","required":false,"schema":{"type":"string","enum":["true","false","1","0"]},"description":"Gambling, adult and piracy sites are excluded unless topic/accepts names the vertical or this is true."},{"name":"plausibility_min_pct","in":"query","required":false,"schema":{"type":"number","minimum":0,"maximum":100},"description":"Keep only placements at or above this percentile of their own price band (placement type x inventory mode x claimed-DR decade). The measured form of price_min."},{"name":"supplier","in":"query","required":false,"schema":{"type":"string","maxLength":64},"description":"Opaque supplier token taken from the prefix of any offer_id. Filters to one source without naming it. Real supplier names are not accepted and match nothing."},{"name":"niche","in":"query","required":false,"schema":{"type":"string"},"description":"The supplier's own raw label. Prefer topic."},{"name":"topic","in":"query","schema":{"type":"array","items":{"type":"string"}},"style":"form","explode":true,"description":"Harmonised subject (e.g. finance, travel, health, technology). Repeatable; repeats are OR-ed."},{"name":"accepts","in":"query","schema":{"type":"array","items":{"type":"string"}},"style":"form","explode":true,"description":"Restricted vertical the placement must accept (crypto, gambling, cbd, vape, adult, dating, pharma, forex). Repeatable; repeats are AND-ed. An empty accepts array on an offer means UNKNOWN, not 'accepts nothing'."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":100},"description":"Rows per page."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"maximum":10000,"default":0},"description":"Maximum 10,000: the catalogue is for searching, not enumerating."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"configured":{"type":"boolean"},"count":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"offers":{"type":"array","items":{"$ref":"#/components/schemas/Offer"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/sites/{domain}":{"get":{"tags":["Catalogue"],"operationId":"compare_domain","summary":"Everything about one publisher","description":"All live listings for one domain, cheapest first, with verification, graph, survival, link history, outcomes (3-buyer floor), placement score, demand, market reference per listing, supplier_count and metric history. The domain is canonicalised, so www/scheme/trailing-slash variants resolve to the same site.","parameters":[{"name":"domain","in":"path","required":true,"schema":{"type":"string"},"description":"A domain or URL."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Site"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Domain not carried.","content":{"application/json":{"schema":{"type":"object","properties":{"configured":{"type":"boolean"},"site_id":{"type":"string"},"found":{"type":"boolean","const":false},"listings":{"type":"array","maxItems":0}}}}}}}}},"/v1/market":{"get":{"tags":["Catalogue"],"operationId":"market_prices","summary":"Price distribution for a slice","description":"What a slice of the market costs, as buyer prices in credits: one price per DOMAIN (the cheapest way to buy it here), so a domain carried by several suppliers counts once. Live, priced, non-delisted named inventory only.","parameters":[{"name":"placement_type","in":"query","required":false,"schema":{"type":"string","enum":["guest_post","niche_edit","link_insert","homepage_link","media_placement"]},"description":"Placement type."},{"name":"inventory_mode","in":"query","required":false,"schema":{"$ref":"#/components/schemas/InventoryMode"},"description":"Inventory mode."},{"name":"dr_min","in":"query","required":false,"schema":{"type":"number","minimum":0,"maximum":100},"description":"Claimed DR floor."},{"name":"dr_max","in":"query","required":false,"schema":{"type":"number","minimum":0,"maximum":100},"description":"Claimed DR ceiling."},{"name":"topic","in":"query","required":false,"schema":{"type":"string","maxLength":64},"description":"Harmonised site topic."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"filters":{"type":"object"},"count":{"type":"integer","description":"Distinct domains behind the distribution."},"offers_considered":{"type":"integer"},"truncated":{"type":"boolean"},"currency":{"type":"string","const":"USD"},"unit":{"type":"string"},"p25":{"type":"integer"},"median":{"type":"integer"},"p75":{"type":"integer"},"min":{"type":"integer"},"max":{"type":"integer"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`no_inventory`: no live inventory matches that slice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/market/taste":{"get":{"tags":["Catalogue"],"operationId":"market_taste","summary":"What buyers in a niche actually buy","description":"Purchase profile of a topic from observed purchases: DR and traffic band shares, placement types, price quartiles, target categories. `insufficient: true` (with min_observations / min_buyers and no counts) under 30 observations or 3 buyers. `winners` and `probable_buyers` blocks from the web graph are present regardless. Shares are over at least three distinct buyers; no targets, no buyers. Cached one hour.","parameters":[{"name":"topic","in":"query","required":true,"schema":{"type":"string","maxLength":60},"description":"Harmonised topic, e.g. finance."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"taste":{"type":"object","properties":{"topic":{"type":"string"},"insufficient":{"type":"boolean"},"min_observations":{"type":"integer"},"min_buyers":{"type":"integer"},"observations":{"type":"integer"},"buyers":{"type":"integer"},"dr_bands":{"type":"array","items":{"type":"object"}},"traffic_bands":{"type":"array","items":{"type":"object"}},"placement_types":{"type":"array","items":{"type":"object"}},"price_credits":{"type":"object"},"top_categories":{"type":"array","items":{"type":"object"}},"winners":{"type":["object","null"]},"probable_buyers":{"type":["object","null"]}}},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/quote":{"post":{"tags":["Planning"],"operationId":"plan_links","summary":"A brief in, a held basket out","description":"Prefer this over assembling a plan from /v1/offers yourself: it collapses each domain to its cheapest seller across every supplier, spreads the basket across domains so it does not read as one relationship, and HOLDS the prices for 4 hours. Each line carries `why`, `relevance`, `subdomain_of`, `quality_reasons` and up to two `alternatives` held at the shown price. Redeem with POST /v1/orders once per line with quote_id set. Nothing is reserved or charged; quoting is free. Accepts a customer key or dashboard session; the operator token is refused.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Brief"}}}},"responses":{"201":{"description":"Held basket.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quote"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"`no_inventory`: nothing matches the brief, or the brief's own gates and budget removed everything. Carries `eligible`, `candidate_coverage` and `excluded` so you can see what removed it; a semantic annotation never does (it can only reorder a `recommended` basket). Widen a filter or change the brief; do not retry it unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"`profile_budget_exhausted` when exclude_domains_linking_to needs a fresh profile beyond the daily budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/quotes/{quote_id}":{"get":{"tags":["Planning"],"operationId":"get_quote","security":[],"summary":"Read a quote by token","description":"The same body POST /v1/quote returned (lines, why, alternatives, expires_at) plus target_url and `expired`, with no customer data (funding, quote_token and view_url are stripped). Public and token-gated, for approval links. A wrong id or token is a 404. Ordering still needs the key or a card.","parameters":[{"name":"quote_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","required":true,"schema":{"type":"string"},"description":"quote_token from the quote."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Quote"},{"type":"object","properties":{"expired":{"type":"boolean"},"note":{"type":"string"}}}]}}}},"404":{"description":"`quote_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/campaigns":{"get":{"tags":["Planning"],"operationId":"campaign_status","summary":"What this account already bought for a target","description":"Campaign memory from the buyer's own orders, grouped as `exact` (the URL; null when only a domain was given) and `domain` (every order pointing anywhere on the host). Each view: orders, links, spend_credits, pending, delivered, failed, cancelled, links_last_30d, first/last_order_at, domains_used[], placement_types, anchor_mix, anchors[]. anchor_mix comes from anchor_plan where given, else from classifying anchor text. `outcomes` is an observed summary, not attribution.","parameters":[{"name":"target_url","in":"query","required":true,"schema":{"type":"string"},"description":"The page the links point at, or a bare domain."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"target_url":{"type":["string","null"]},"target_domain":{"type":"string"},"outcomes":{},"exact":{"type":["object","null"]},"domain":{"type":"object"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/targets/profile":{"get":{"tags":["Research"],"operationId":"target_profile","summary":"Backlink profile of a target's domain","description":"From DataForSEO: referring_domains, backlinks, backlinks_rank, spam_score, rank_bands (each referring domain's own rank, 0-1000), dr_bands (claimed DR, carried domains only), topics, anchor_mix (pass brand and keyword so exact/partial are told from topical), velocity_12m, top_referring[], carried, carried_share. Two scales, never merged. Cached 7 days; 20 fresh fetches per customer per day, cached reads unlimited.","parameters":[{"name":"url","in":"query","required":true,"schema":{"type":"string"},"description":"A page URL or a domain."},{"name":"brand","in":"query","required":false,"schema":{"type":"string","maxLength":80},"description":"Brand name for anchor classification."},{"name":"keyword","in":"query","required":false,"schema":{"type":"string","maxLength":120},"description":"Target keyword for anchor classification."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"target_url":{"type":"string"},"target_domain":{"type":"string"},"fetched_at":{"type":"string"},"cached":{"type":"boolean"},"referring_domains":{"type":["integer","null"]},"referring_main_domains":{"type":["integer","null"]},"backlinks":{"type":["integer","null"]},"backlinks_rank":{"type":["integer","null"]},"spam_score":{"type":["number","null"]},"first_seen":{"type":["string","null"]},"referring_fetched":{"type":"integer"},"rank_bands":{"type":"object"},"rank_bands_basis":{"type":"string"},"dr_bands":{"type":"object"},"dr_bands_basis":{"type":"string"},"topics":{"type":"array","items":{"type":"object"}},"anchor_mix":{"type":"object"},"anchors_classified":{"type":"integer"},"velocity_12m":{"type":"array","items":{"type":"object"}},"top_referring":{"type":"array","items":{"type":"object","properties":{"domain":{"type":"string"},"rank":{"type":["integer","null"]},"link_rank":{"type":["integer","null"]},"first_seen":{"type":["string","null"]},"nofollow_share":{"type":["number","null"]},"carried":{"type":"boolean"}}}},"carried":{"type":"integer"},"carried_share":{"type":"number"},"cache":{"type":"object"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"description":"`profile_budget_exhausted`: 20 new backlink profiles per day.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/targets/gap":{"post":{"tags":["Research"],"operationId":"gap_map","summary":"Competitors' referring domains the target lacks, priced and held","description":"Domains linking to the competitors and not to the target, joined to the catalogue: rows we can sell come back as quote lines (held for 4 hours, orderable with quote_id exactly like /v1/quote lines) plus competitors_linking and linking_competitors; what we cannot sell is a count and a sample. Suspect domains are excluded. Without competitors, DataForSEO's backlink competitors are used and competitors_source says so. Cached 24h per target and competitor set; 20 fresh fetches per customer per day.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["target_url"],"additionalProperties":false,"properties":{"target_url":{"type":"string","maxLength":2048},"competitors":{"type":"array","maxItems":5,"items":{"type":"string"}},"topic":{"type":"string","maxLength":60},"placement_type":{"type":"string","maxLength":40},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50}}}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"target_domain":{"type":"string"},"competitors":{"type":"array","items":{"type":"string"}},"competitors_source":{"type":"string"},"cached":{"type":"boolean"},"fetched_at":{"type":"string"},"gap_total":{"type":"integer"},"gap_fetched":{"type":"integer"},"carried":{"type":"integer"},"carried_share":{"type":"number"},"quote_id":{"type":["string","null"]},"expires_at":{"type":["string","null"]},"ttl_seconds":{"type":"integer"},"binding":{"type":"boolean"},"total_credits":{"type":"integer"},"excluded":{"type":"object"},"rows":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/QuoteLine"},{"type":"object","properties":{"competitors_linking":{"type":"integer"},"linking_competitors":{"type":"array","items":{"type":"string"}}}}]}},"not_carried":{"type":"integer"},"not_carried_sample":{"type":"array","items":{"type":"object","properties":{"domain":{"type":"string"},"competitors_linking":{"type":"integer"},"link_rank":{"type":["integer","null"]}}}},"cache":{"type":"object"},"next":{"type":"object"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"`no_competitors`: none found sharing referring domains; name some.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"`gap_budget_exhausted`: 20 new gap fetches per day.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/keywords/winners":{"get":{"tags":["Research"],"operationId":"keyword_winners","summary":"Who ranks for a keyword, who links to them, what we sell","description":"Top 30 ranking domains for a keyword (fresh SERP fetched within a 200/day budget when none under 60 days is held), the domains linking to two or more of them (or to one we carry), and which we carry with the cheapest offer_id, price_credits and verification. `leaders_with_referrers` says how many leaders have been profiled; shared_referrers is over those.","parameters":[{"name":"keyword","in":"query","required":true,"schema":{"type":"string","maxLength":120},"description":"The keyword."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"keyword":{"type":"string"},"serp_fetched_at":{"type":["string","null"]},"serp_fresh":{"type":"boolean"},"leaders":{"type":"array","items":{"type":"object","properties":{"domain":{"type":"string"},"position":{"type":"integer"},"url":{"type":"string"}}}},"leaders_with_referrers":{"type":"integer"},"shared_referrers":{"type":"array","items":{"type":"object","properties":{"domain":{"type":"string"},"leaders_linked":{"type":"integer"},"authority_rank":{"type":["integer","null"]},"carried":{"type":"boolean"},"offer_id":{"type":["string","null"]},"price_credits":{"type":["integer","null"]},"verification":{"$ref":"#/components/schemas/Verification"}}}},"carried":{"type":"integer"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"description":"`serp_budget_exhausted`: 200 fresh keyword SERPs per day.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/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"}}}},"/v1/orders/{order_id}/content":{"post":{"tags":["Content"],"operationId":"submit_content","summary":"Send the article","description":"For an awaiting_content or content_revision_requested order. Validated against the publisher's spec; 422 lists the problems. `status: \"draft\"` stores a version without submitting (nothing charged, order status unchanged) — Papi's draft for approval; resend with `status: \"submitted\"` to approve. A submission moves the order to `ordered` for the next batch. Nothing further is charged; the papi_written fee was taken with the order. Requires the `order` scope.","parameters":[{"name":"order_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ContentBody"},{"type":"object","properties":{"status":{"type":"string","enum":["draft","submitted"],"default":"submitted"},"submitted_by":{"type":"string","enum":["customer","papi"],"default":"customer"}}}]}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"order_id":{"type":"string"},"version":{"type":"integer"},"content_status":{"type":"string","enum":["draft","submitted"]},"order_status":{"$ref":"#/components/schemas/OrderStatus"},"charged_credits":{"type":"integer"},"validation":{"$ref":"#/components/schemas/ContentValidation"},"next":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"A safety-net papi_written charge could not be covered; carries `validation`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`order_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`content_not_expected` (publisher or supplier writes), `order_not_open`, `content_already_submitted`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`content_invalid`, with `validation`. Send status 'draft' to store it anyway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}},"get":{"tags":["Content"],"operationId":"get_order_content","summary":"Every version sent","description":"All versions with their validation and any supplier rejection note (vendor_note), plus the spec and rules.","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"},"order_status":{"$ref":"#/components/schemas/OrderStatus"},"content_mode":{"type":"string","enum":["publisher","service","supplied","papi_written"]},"spec":{"$ref":"#/components/schemas/ContentSpec"},"rules":{"type":"array","items":{"type":"string"}},"versions":{"type":"array","items":{"type":"object","properties":{"version":{"type":"integer"},"status":{"type":"string","description":"draft | submitted | rejected | superseded"},"submitted_by":{"type":"string"},"title":{"type":["string","null"]},"body_markdown":{"type":["string","null"]},"body_html":{"type":["string","null"]},"doc_url":{"type":["string","null"]},"meta_description":{"type":["string","null"]},"word_count":{"type":["integer","null"]},"validation":{"$ref":"#/components/schemas/ContentValidation"},"vendor_note":{"type":["string","null"]},"created_at":{"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"}}}},"/v1/orders/{order_id}/content/papi":{"post":{"tags":["Content"],"operationId":"write_it_for_me","summary":"Hand the article to Placement Papi now","description":"For an awaiting_content or content_revision_requested order: charges the 1,500-credit tier once (nothing if a writing tier was already paid, or if already papi_written) and returns the order to awaiting_content at version 0 for Papi to draft. 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":{"ok":{"type":"boolean"},"order_id":{"type":"string"},"status":{"type":"string"},"content_mode":{"type":"string"},"charged_credits":{"type":"integer"},"note":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Balance cannot cover the fee; carries price_credits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`order_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`order_not_open`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/credits":{"get":{"tags":["Account"],"operationId":"check_balance","summary":"Balance, limits, allowance and recent ledger","description":"Balance is read from the append-only ledger. `funding.cash_on_hand_credits` is money that has cleared; a top-up is spendable immediately but an order is only SENT to a supplier once the money behind it settles. `read_allowance` shows the monthly row entitlement before a 429. `refund` shows what an unspent balance would return today.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"balance_credits":{"type":"integer","description":"Credits. 1 credit = 1 US cent."},"funding":{"type":["object","null"],"properties":{"cash_on_hand_credits":{"type":"integer"},"pending_credits":{"type":"integer"},"settles_immediately":{"type":"boolean"},"hold_hours":{"type":"integer"}}},"read_allowance":{"type":"object","properties":{"rows_used_this_month":{"type":"integer"},"rows_allowed_this_month":{"type":"integer"},"note":{"type":"string"}}},"balance_usd":{"type":"string","description":"Credits are pegged 1:1 to US cents, so this is exact."},"limits":{"type":"object","properties":{"per_order_max_credits":{"type":["integer","null"]},"daily_max_credits":{"type":["integer","null"]},"spent_today_credits":{"type":"integer"},"remaining_today_credits":{"type":["integer","null"]}}},"refund_grace_days":{"type":["integer","null"],"description":"The account grace period, in days, applied once. On timing alone an undelivered order is flagged `refund_eligible` from due_at plus one account grace period. A credit refund for an order already with a supplier is guarded separately by due_at plus the supplier's refund window plus that same one account grace period, and it also requires that we have recovered from the supplier, unless we choose to bridge it as a goodwill decision. Passing the timing threshold alone never returns credits."},"volume_credits_earned":{"type":"integer"},"refund":{"type":"object","properties":{"refundable_credits":{"type":"integer"},"fee_credits":{"type":"integer"},"payout_credits":{"type":"integer"},"eligible":{"type":"boolean"},"reason":{"type":["string","null"]},"window_days":{"type":"integer"},"note":{"type":"string"}}},"recent_entries":{"type":"array","maxItems":50,"items":{"type":"object","properties":{"entry_id":{"type":"integer"},"kind":{"type":"string"},"amount_credits":{"type":"integer"},"order_id":{"type":["string","null"]},"memo":{"type":["string","null"]},"receipt_url":{"type":["string","null"]},"created_at":{"type":"string"}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/credits/checkout":{"post":{"tags":["Account"],"operationId":"create_topup_checkout","summary":"Start a card top-up","description":"Returns a Stripe Checkout URL. Credits are granted by the payment webhook when the payment clears, not by this response. Card only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount_cents"],"properties":{"amount_cents":{"type":"integer","minimum":5000,"maximum":10000000,"description":"Cents, which is also credits ($50 to $100,000)."},"save_card":{"type":"boolean","default":false,"description":"Keep the card on file for auto-reload (recorded at Stripe as setup_future_usage)."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"checkout_url":{"type":["string","null"]},"session_id":{"type":"string"},"amount_cents":{"type":"integer"},"credits":{"type":"integer"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`customer_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/credits/settings":{"get":{"tags":["Account"],"operationId":"get_spend_settings","summary":"Spend caps and auto-reload","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"limits":{"type":"object","properties":{"per_order_max_credits":{"type":["integer","null"]},"daily_max_credits":{"type":["integer","null"]}}},"auto_reload":{"type":"object","properties":{"enabled":{"type":"boolean"},"threshold_credits":{"type":["integer","null"]},"amount_credits":{"type":["integer","null"]},"has_saved_card":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`customer_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}},"patch":{"tags":["Account"],"operationId":"update_spend_settings","summary":"Change spend caps (dashboard session only)","description":"NOT available to API keys: a key that could raise its own cap would not be a cap. Returns 403 `forbidden` for any Bearer key. Null clears a cap; an omitted field is left alone. Auto-reload needs both threshold and amount, or neither, and amount must exceed threshold.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"per_order_max_credits":{"type":["integer","null"],"minimum":1},"daily_max_credits":{"type":["integer","null"],"minimum":1},"auto_reload_threshold_credits":{"type":["integer","null"],"minimum":1},"auto_reload_amount_credits":{"type":["integer","null"],"minimum":5000,"maximum":10000000}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"limits":{"type":"object"},"auto_reload":{"type":"object","properties":{"threshold_credits":{"type":["integer","null"]},"amount_credits":{"type":["integer","null"]}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`forbidden`: API keys cannot change spend limits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/keys":{"get":{"tags":["Account"],"operationId":"list_keys","summary":"Your API keys","description":"Secrets are never returned; `prefix` is enough to recognise a key. `current` marks the key making the request.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"object","properties":{"key_id":{"type":"string"},"name":{"type":["string","null"]},"prefix":{"type":"string"},"created_at":{"type":"string"},"last_used_at":{"type":["string","null"]},"revoked":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string","enum":["read","order"]}},"can_order":{"type":"boolean"},"current":{"type":"boolean"}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}},"post":{"tags":["Account"],"operationId":"create_key","summary":"Issue a key","description":"The secret is shown once and never again. `name` is trimmed and limited to 80 characters; a blank name is not stored blank, it gets a descriptive default naming what the key can do ('read-only key' or 'ordering key'). Scopes default to ['read']; a read-only key cannot issue a key with the `order` scope (403). At most 20 active keys.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":80,"description":"A label so the key list stays readable, trimmed. Blank or omitted stores the scope-based default ('read-only key' or 'ordering key')."},"scopes":{"type":"array","items":{"type":"string","enum":["read","order"]},"default":["read"],"description":"Ordering is opt-in per key. An explicit array matching neither scope is refused (400) rather than silently issuing a read-only key."}}}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"key_id":{"type":"string"},"name":{"type":"string","description":"The effective stored name: the trimmed name sent, or the scope-based default when blank."},"scopes":{"type":"array","items":{"type":"string"}},"can_order":{"type":"boolean"},"key":{"type":"string","description":"The pk_live_… secret. Shown once."},"warning":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`forbidden`: a read-only key cannot mint an ordering key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`too_many_keys`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/keys/{key_id}":{"patch":{"tags":["Account"],"operationId":"rename_key","summary":"Rename a key","description":"Changes a key's label and nothing else: a name is not a capability, so (unlike revocation) this is safe on the key you are holding. Scoped to your own ACTIVE keys — a revoked key, or one belonging to another buyer, answers 404 rather than confirming the id exists. The name is trimmed to 1-80 characters.","parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":80,"description":"The new label, required and non-blank after trimming."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"key_id":{"type":"string"},"name":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`not_found`: no active key with that id belongs to you.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}},"delete":{"tags":["Account"],"operationId":"revoke_key","summary":"Revoke a key","description":"Scoped to your own keys. Revoking the key you are using is refused with 409 unless `?confirm=self`.","parameters":[{"name":"key_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"confirm","in":"query","required":false,"schema":{"type":"string","enum":["self"]},"description":"Required to revoke the current key."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"key_id":{"type":"string"},"revoked":{"type":"boolean","const":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`would_revoke_current_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/customers/me":{"get":{"tags":["Account"],"operationId":"get_account_profile","summary":"Your account identity (dashboard session only)","description":"Returns the signed-in buyer's `{ customer_id, name, email }`. NOT available to API keys: a key may spend on the account's behalf, but it is not a person, so any Bearer key gets 403 `dashboard_only`. Requires a live browser session.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"customer_id":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`dashboard_only`: personal identity needs a signed-in browser session, not an API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`not_found`: the account no longer exists or is inactive.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}},"patch":{"tags":["Account"],"operationId":"update_account_profile","summary":"Change your display name (dashboard session only)","description":"Sets the display name and nothing else: one column, so credits, keys, orders and Stripe identity cannot move with it. NOT available to API keys (403 `dashboard_only`); requires a live browser session and a same-origin `Origin` header or the write is refused (403 `forbidden`). The name is trimmed; 1-80 characters after trimming.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"additionalProperties":false,"properties":{"name":{"type":"string","maxLength":400,"description":"Trimmed before the 80-character bound is applied, so stray leading or trailing space is not counted."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"customer_id":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`dashboard_only` for an API key; `forbidden` for a cross-origin request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/customers/me/email":{"post":{"tags":["Account"],"operationId":"request_email_change","summary":"Start a verified email change (dashboard session only)","description":"Session only and cross-origin protected. Checks the requested address against this account's current address and against every other account, rate-limits per account (3 an hour), then emails a single-use, 30-minute confirmation link to the NEW inbox. Nothing on the account changes yet. NOT available to API keys (403 `dashboard_only`): a key that could redirect the account's mail would be an identity, not a program.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"additionalProperties":false,"properties":{"email":{"type":"string","format":"email","maxLength":254,"description":"The new address; normalized to lowercase."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"sent":{"type":"boolean","const":true},"email":{"type":"string","description":"The new inbox the confirmation was sent to."},"detail":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`dashboard_only` for an API key; `forbidden` for a cross-origin request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`email_unchanged` when that is already this account's address; `email_taken` when another account holds it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"`rate_limited`: up to 3 email-change requests per account an hour.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"`email_send_failed`: the confirmation mail could not be sent, so nothing changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/customers/me/email/confirm":{"post":{"tags":["Account"],"operationId":"confirm_email_change","summary":"Confirm an email change (dashboard session only)","description":"Session only and cross-origin protected. Send the token in the JSON POST BODY, never the URL query; the emailed link carries it in the URL fragment, which browsers do not transmit. The live session must belong to the SAME account the token is bound to (not necessarily the browser that began the change); the token is single-use and expires after 30 minutes, and the account must still carry the address it had when the change was requested. The account update, the single-use burn, the end of every browser session on the account and the burn of unconsumed sign-in links for the PREVIOUS address all happen in one SQL statement, so an invalid, replayed or conflicting confirmation changes none of them. Because every session ends, the buyer is signed out and must sign in again with the new address; API keys are left alone. On success the previous inbox is notified, with no link and no token. The committed email is best-effort mirrored onto the account's Stripe customer; that outcome is reported as `billing_email_sync` and never rolls the change back. NOT available to API keys (403 `dashboard_only`).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"additionalProperties":false,"properties":{"token":{"type":"string","minLength":1,"maxLength":200,"description":"The single-use token from the confirmation email. In the POST body, not the query string."}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","required":["customer_id","name","email","billing_email_sync"],"properties":{"customer_id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string","description":"The newly verified address. All sessions were ended, so the client must sign in again with this address."},"billing_email_sync":{"type":"string","enum":["not_needed","updated","pending"],"description":"Whether the buyer's Stripe billing customer was moved to the new address. `not_needed`: the account has no Stripe customer, so no call was made. `updated`: Stripe accepted the new address. `pending`: the account email DID change, but the Stripe mirror did not; billing mail may still reach the previous address and the failure is recorded for an operator. Pending never means the email change failed."},"billing_email_sync_reason":{"type":"string","enum":["not_configured","stripe_error","lookup_failed"],"description":"Present only when `billing_email_sync` is `pending`. `not_configured`: no Stripe key on this deployment. `stripe_error`: Stripe rejected the customer update. `lookup_failed`: the account's Stripe customer id could not be read."}}}}}},"400":{"description":"`invalid_token`: missing, malformed, expired, already used, or not for this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`dashboard_only` for an API key; `forbidden` for a cross-origin request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`email_taken`: another account claimed the address before this completed; no change was made.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/auth/logout":{"post":{"tags":["Account"],"operationId":"logout","security":[],"summary":"End the browser session","description":"Public and idempotent: deletes the browser session row and expires its cookie in one call, so a stale or already-ended cookie is cleared rather than left live. A Bearer API key without a session cookie has nothing to end. A present `Origin` header that is not the request's own is refused (403 `forbidden`) before anything is deleted.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true}}}}}},"403":{"description":"`forbidden`: cross-origin request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/customers/me/brief":{"get":{"tags":["Account"],"operationId":"get_weekly_brief","summary":"Weekly campaign brief settings","description":"Off by default. When on, once a week per target with recent orders (or the targets listed), the account email gets what landed, what is due, what moved, and a held quote for the next tranche with an approval link.","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"weekday":{"type":"integer","minimum":1,"maximum":7,"description":"ISO weekday, 1 = Monday."},"targets":{"type":"array","items":{"type":"string"}},"last_sent_at":{"type":["string","null"]},"email":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}},"patch":{"tags":["Account"],"operationId":"update_weekly_brief","summary":"Turn the weekly brief on or off","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"weekday":{"type":"integer","minimum":1,"maximum":7},"targets":{"type":"array","maxItems":50,"items":{"type":"string","maxLength":2048}}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"weekday":{"type":"integer"},"targets":{"type":"array","items":{"type":"string"}},"last_sent_at":{"type":["string","null"]},"email":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/customers/me/auto":{"get":{"tags":["Account"],"operationId":"get_auto_placement","summary":"Whether auto placement is enabled","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"auto_placement_enabled":{"type":"boolean"},"auto_ok_at":{"type":["string","null"]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}},"post":{"tags":["Account"],"operationId":"set_auto_placement","summary":"Enable or disable auto placement (dashboard session only)","description":"NOT available to API keys: returns 403 `dashboard_only` for any Bearer key. This is the one switch that lets standing orders in 'auto' mode spend with nobody watching, so it takes a signed-in human on the account page. A key may create a standing order in auto mode; it cannot make that mode do anything.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"auto_placement_enabled":{"type":"boolean"},"auto_ok_at":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`dashboard_only`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/standing_orders":{"get":{"tags":["Account"],"operationId":"standing_orders","summary":"List standing orders","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"standing_orders":{"type":"array","items":{"$ref":"#/components/schemas/StandingOrder"}},"auto_placement_enabled":{"type":"boolean"},"note":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}},"post":{"tags":["Account"],"operationId":"create_standing_order","summary":"Create a standing order","description":"Requires the `order` scope. First run defaults to now + cadence. In auto mode with auto placement not enabled on the account, runs email an approval link instead and the response note says so. Auto-placed lines default to papi_written content where no content_mode is set, so an unattended run never stalls waiting for an article.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["target_url","cadence","links_per_run","budget_credits_per_run"],"properties":{"target_url":{"type":"string","format":"uri","maxLength":2048},"brief":{"type":"object","default":{},"description":"A plan_links brief; links and budget_credits are overridden per run."},"cadence":{"type":"string","enum":["weekly","biweekly","monthly"]},"links_per_run":{"type":"integer","minimum":1,"maximum":50},"budget_credits_per_run":{"type":"integer","minimum":100},"mode":{"type":"string","enum":["approve","auto"],"default":"approve"},"anchor_plan":{"$ref":"#/components/schemas/AnchorPlan"},"if_no_content":{"type":"string","enum":["convert_to_service","cancel"]},"content_mode":{"type":"string","enum":["publisher","service","supplied","papi_written"]},"first_run_at":{"type":"string","format":"date-time"}}}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"standing_order_id":{"type":"string"},"next_run_at":{"type":"string"},"mode":{"type":"string"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/standing_orders/{id}":{"patch":{"tags":["Account"],"operationId":"update_standing_order","summary":"Pause, resume or edit a standing order","description":"Requires the `order` scope. Omitted fields are left alone; anchor_plan, if_no_content and content_mode accept null to clear.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"status":{"type":"string","enum":["active","paused"]},"brief":{"type":"object"},"cadence":{"type":"string","enum":["weekly","biweekly","monthly"]},"links_per_run":{"type":"integer","minimum":1,"maximum":50},"budget_credits_per_run":{"type":"integer","minimum":100},"mode":{"type":"string","enum":["approve","auto"]},"anchor_plan":{"oneOf":[{"$ref":"#/components/schemas/AnchorPlan"},{"type":"null"}]},"if_no_content":{"type":["string","null"],"enum":["convert_to_service","cancel",null]},"content_mode":{"type":["string","null"],"enum":["publisher","service","supplied","papi_written",null]},"next_run_at":{"type":"string","format":"date-time"}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StandingOrder"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}},"delete":{"tags":["Account"],"operationId":"delete_standing_order","summary":"Remove a standing order","description":"Requires the `order` scope.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"standing_order_id":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"`not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/checkout/basket":{"post":{"tags":["Checkout"],"operationId":"checkout_basket","summary":"Buy a basket by card, with or without an account","description":"Auth optional. Without a key: `email` is required, payment creates the account, and the buyer receives a sign-in link with their orders placed. With a customer key: the balance is spent first and only the shortfall is charged; a basket the balance covers is placed immediately with no card (checkout_url null), and an Idempotency-Key header is REQUIRED. A card processing fee (2.9% + 30c, grossed up) is a separate line item. Nothing is charged or ordered by this call itself; orders are placed by the payment webhook once money is captured. A presented-but-invalid key is a 401, not a guest. An optional buyer-approved `max_total_credits` ceiling refuses the checkout with 409 `price_changed` if the reviewed total has risen, so a catalogue move between review and buy is never charged silently; a purchase is only ever placed on an explicit buyer action, never by the comparison itself. Limited to 10 baskets an hour per address and per customer.","security":[{},{"apiKey":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Required when authenticated; optional for guests (the card form is the confirmation)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["return_url","lines"],"properties":{"email":{"type":"string","format":"email","description":"Required when unauthenticated; ignored when a key is sent."},"return_url":{"type":"string","format":"uri","description":"https URL on an allowed host; where the buyer is sent back with ?basket=&token= appended."},"max_total_credits":{"type":"integer","minimum":1,"description":"Optional, additive buyer-approved ceiling on the whole basket in credits (placement + content). When the audited total has risen above it, the checkout is refused with 409 `price_changed` carrying `current_total_credits` and `max_total_credits`, and nothing is debited, charged or ordered; the buyer must compare again and accept the current total. Absent, the route behaves exactly as before (`repriced` tolerance unchanged). This is the ceiling the hosted Perch review sends as the total it showed the buyer."},"lines":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["offer_id","target_url"],"properties":{"offer_id":{"type":"string"},"quote_id":{"type":"string"},"target_url":{"type":"string","format":"uri"},"anchor_text":{"type":["string","null"],"maxLength":300},"target_keyword":{"type":["string","null"],"maxLength":200},"content_mode":{"type":"string","enum":["publisher","service","supplied","papi_written"]},"content_tier":{"type":["string","null"],"maxLength":60,"description":"A paid writing tier by name when content_mode is service; the cheapest when omitted."},"if_no_content":{"type":"string","enum":["convert_to_service","cancel"]}}}}}}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"basket_id":{"type":"string"},"status_token":{"type":"string"},"checkout_url":{"type":["string","null"],"description":"Null when the balance covered everything."},"status":{"type":"string"},"total_credits":{"type":"integer","description":"placement_credits + content_credits."},"placement_credits":{"type":"integer"},"content_credits":{"type":"integer","description":"Writing fees priced into the basket (service tier or papi_written)."},"fee_cents":{"type":"integer"},"charge_cents":{"type":"integer"},"balance_used_credits":{"type":"integer"},"shortfall_credits":{"type":"integer"},"expires_at":{"type":"string","format":"date-time"},"replayed":{"type":"boolean"},"lines":{"type":"array","items":{"type":"object"}}}}}}},"400":{"description":"`bad_request`, `return_url_not_allowed`, `email_required`, `idempotency_key_required`, `empty_basket`, `anchor_text_required` (with offer_id).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"`customer_key_required`: the operator token cannot buy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`repriced` with lines[{ offer_id, was_credits, now_credits }] when a held price moved more than 10%; `price_changed` (with `current_total_credits` and `max_total_credits`) when the basket now costs more than the buyer-approved `max_total_credits` ceiling, in which case nothing is debited or ordered and the buyer must compare again; `basket_already_resolved`; or a line problem such as `offer_not_found` naming the offer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/checkout/basket/{basket_id}":{"get":{"tags":["Checkout"],"operationId":"checkout_basket_status","security":[],"summary":"How a checkout went","description":"Public and token-gated, because the buyer may have no account yet. Poll after returning from the card form. A wrong token is a 404, not a 403. On `partial`, credits for unplaced lines stay on the buyer's balance. Says nothing else about the customer.","parameters":[{"name":"basket_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","required":true,"schema":{"type":"string"},"description":"status_token from the checkout response."}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","description":"pending | paid | placed | partial | failed | abandoned."},"total_credits":{"type":"integer"},"fee_cents":{"type":"integer"},"charge_cents":{"type":"integer"},"balance_used_credits":{"type":"integer"},"lines":{"type":"array","items":{"type":"object","properties":{"offer_id":{"type":"string"},"domain":{"type":["string","null"]},"price_credits":{"type":"integer"},"order_id":{"type":["string","null"]},"cancellable":{"type":"boolean"},"error":{"type":["string","null"]}}}},"dashboard_url":{"type":"string"},"email_hint":{"type":["string","null"],"description":"Masked."}}}}}},"404":{"description":"`not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/observations/purchases":{"post":{"tags":["Research"],"operationId":"report_purchases","security":[],"summary":"Report placements bought anywhere","description":"Open to all; rate limited per address (5,000 rows/hour). Send your API key to be counted as a distinct buyer; anonymous reports are stored but count as one buyer per sending address. Feeds demand and niche taste, read only in aggregate behind a three-buyer floor. Anchor text is never stored, only its type; a buyer_ref that looks like an email is hashed; duplicates within 30 days are one observation. Nothing sent here is returned to another caller.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["buyer_ref","target_domain","rows"],"properties":{"buyer_ref":{"type":"string","maxLength":320},"target_domain":{"type":"string"},"target_url":{"type":["string","null"],"format":"uri"},"target_topics":{"type":["array","null"],"maxItems":12,"items":{"type":"string","maxLength":60}},"target_category":{"type":["string","null"],"maxLength":200},"rows":{"type":"array","minItems":1,"maxItems":200,"items":{"type":"object","required":["placement_domain"],"properties":{"placement_domain":{"type":"string"},"placement_type":{"type":["string","null"],"enum":["guest_post","niche_edit","link_insert","homepage_link","media_placement",null]},"price_paid_credits":{"type":["integer","null"]},"vendor_label":{"type":["string","null"],"maxLength":120},"anchor_type":{"type":["string","null"],"maxLength":40},"purchased_at":{"type":["string","null"]},"raw":{}}}}}}}}},"responses":{"201":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"accepted":{"type":"integer"},"enriched":{"type":"integer"},"not_carried":{"type":"integer"},"duplicates":{"type":"integer"},"rejected":{"type":"array","items":{}}}}}}},"400":{"description":"`bad_request`, or nothing accepted (same counts body).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/v1/perch/compare":{"post":{"tags":["Catalogue"],"operationId":"perch_compare","summary":"Compare on-screen placements without an account","security":[],"description":"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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BasketCompareRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BasketCompareResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"413":{"description":"Payload too large"},"429":{"description":"Public comparison limit reached"},"503":{"description":"Comparison unavailable"}}}},"/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.\n\nRead 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.\n\nEach 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.\n\nNumeric 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"}}}}}}},"/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"}}}}}}