---
title: "Buy a basket by card, with or without an account"
method: POST
path: "/v1/checkout/basket"
tags: ["Checkout"]
---

# Buy a basket by card, with or without an account

`POST /v1/checkout/basket`

## Request

- Base URL: `https://api.placementapi.com`
- URL: `https://api.placementapi.com/v1/checkout/basket`
- Auth: one of:
  - none
  - HTTP bearer

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.

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `email` string, email: Required when unauthenticated; ignored when a key is sent.
  - `return_url` string, uri, required: https URL on an allowed host; where the buyer is sent back with ?basket=&token= appended.
  - `max_total_credits` integer: 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` object[], required
    - `offer_id` string, required
    - `quote_id` string
    - `target_url` string, uri, required
    - `anchor_text` string, nullable
    - `target_keyword` string, nullable
    - `content_mode` 'publisher' | 'service' | 'supplied' | 'papi_written'
    - `content_tier` string, nullable: A paid writing tier by name when content_mode is service; the cheapest when omitted.
    - `if_no_content` 'convert_to_service' | 'cancel'

## Response `201`

OK

- object
  - `basket_id` string
  - `status_token` string
  - `checkout_url` string, nullable: Null when the balance covered everything.
  - `status` string
  - `total_credits` integer: placement_credits + content_credits.
  - `placement_credits` integer
  - `content_credits` integer: Writing fees priced into the basket (service tier or papi_written).
  - `fee_cents` integer
  - `charge_cents` integer
  - `balance_used_credits` integer
  - `shortfall_credits` integer
  - `expires_at` string, date-time
  - `replayed` boolean
  - `lines` object[]

## Other responses

- `400`: `bad_request`, `return_url_not_allowed`, `email_required`, `idempotency_key_required`, `empty_basket`, `anchor_text_required` (with offer_id).
- `401`: Missing, malformed or revoked Bearer credential: `{ error: "unauthorized" }`.
- `403`: `customer_key_required`: the operator token cannot buy.
- `409`: `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.
- `429`: 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.
- `503`: `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.

---

[API](https://skmtc.dev/placementapi/apis/placement-api.md) · [All operations](https://skmtc.dev/placementapi/apis/placement-api/llms.txt) · [OpenAPI document](https://skmtc.dev/placementapi/apis/placement-api/revisions/fc4e7866eaf7?raw)
