pitchko Integration Guide

Pitchko API v1

Generate pitch decks programmatically. Authenticate with an API key and call two endpoints: one to start a deck, one to poll its status.

Every endpoint path in this document is relative to the base URL: POST /decks means POST https://pitchko.io/api/v1/decks. Do not prepend /v1 again — https://pitchko.io/api/v1/v1/decks is a 404. The one exception is the MCP server (§8): it lives outside the versioned base, at https://pitchko.io/api/mcp exactly as written.


1. Authentication

Getting a key

Keys are self-serve. There is no shared or public sandbox key — every key, including a pk_test_ one, belongs to a specific Pitchko user, so an invented pk_test_... string is rejected with 401 like any other bad credential.

  1. Create a Pitchko account (free) at https://pitchko.io/signup, or sign in to an existing one.
  2. Go to Dashboard → Settings → Developers and create a key.
  3. Turn Sandbox on for a pk_test_ key. Start here — it is free and charges nothing (§2).
  4. Pick the scopes the integration needs (§10): decks to create and read decks, aeo:read for the AI-visibility and MCP read surfaces.

The key is shown once, at creation. Store it then; it cannot be retrieved afterwards, only rotated.

Integrating for someone else's agency? A key acts as the user who minted it — same agency, same balance, same permissions. So either they mint a sandbox key and send it to you, or you build against a key on your own free account and they swap in their pk_live_ key at go-live. No code changes either way; the key is the only difference.

Using it

Every request must carry the key as a bearer token:

Authorization: Bearer pk_live_<your key>

Two kinds of keys:

Prefix Mode Credits charged Real deck generated
pk_live_ Live Yes — the amount depends on the deck Yes
pk_test_ Sandbox No No — simulated

A deck's price is not a flat constant: it follows what you asked for, so slide_image_source: aiGenerated costs more than pictographic. The panel shows the price of each setting before you commit to it, and the charge is visible on the account's credit history afterwards. This document does not restate the numbers — the account's own plan is where they live, and a price copied into a reference is a price that goes stale without telling anyone.

Keep the key secret. A missing or invalid key returns 401 { "error": "unauthorized" }.


2. Sandbox (free integration testing)

Use a pk_test_ key while building your integration. Sandbox requests never cost anything: no credits are charged and no real generation runs. The deck's status is simulated from elapsed time so you can test your polling loop:

Time since create Status
0–5 s pending
5–15 s processing
15 s + completed

The result URLs returned for a completed sandbox deck are illustrative placeholders, not downloadable files. They live on sandbox.invalid — a reserved domain that never resolves — precisely so they cannot be mistaken for a broken download. They show where the real pdf_url / pptx_url will appear; test your parsing against their shape, not their contents. Switch to a pk_live_ key for real decks — no code changes, just the key.


3. Create a deck

POST /decks

All selection fields (anything that is a fixed choice, not free text) are required and validated against a closed set — an unexpected value is rejected with 400 and no deck is generated.

Required fields

Field Type Allowed values
brand_name string 1–200 chars
brand_url string Valid URL
country enum ISO 3166-1 alpha-2, one of the supported markets (see list below)
language string Deck language — any code this deployment ships a pack for (always incl. tr, en)
tone_prefix enum professional · data_driven · bold
content_density enum concise · standard · detailed
slide_image_source enum pictographic · aiGenerated · noImagesnarrowed 2026-08, see §10

Conditionally required: slide_image_style

If — and only if — slide_image_source is aiGenerated, you must also send slide_image_style. It has no default: the look of the imagery is a visual decision that belongs to you, so we do not pick one on your behalf. A request that asks for AI images without naming a style is rejected with 400 and no deck is generated.

slide_image_style Looks like
clean-minimal Restrained, lots of white space, few elements
photorealistic Photographic; closest to a stock-photo look
flat-illustration Flat vector illustration, solid fills
gradient-abstract Abstract shapes and gradients, no literal subjects
dark-premium Dark ground, high contrast, premium/enterprise register
isometric-3d Isometric 3D scenes and objects

For the other two sources the field is forbidden, not merely unused: pictographic and noImages produce no AI imagery, so a style would have nothing to apply to and sending one is a 400. This is the same reject-rather-than-ignore rule the rest of §3 follows — a silently dropped field is how an integrator ends up believing they configured something they did not.

Optional fields

Field Type Notes
target_country enum Research-market override (ISO 3166-1 alpha-2, same supported markets as country). Omit to inherit country. Affects keyword/SERP/trends/ad research geo only — not the deck language or content
slide_text_mode enum condensenarrowed 2026-08, see §10
slide_template_id string Defaults to our standard template. If provided, must be a valid active template id
excluded_sections string[] Section ids to skip (see list below). Omit to include all sections
agency_id uuid Required only if your account belongs to more than one agency
competitor_urls string[] Up to 10 valid URLs
notes string Free-form brief, ≤ 5000 chars
budget_range string Free-form budget label, ≤ 100 chars

Unknown fields are rejected (400).

Supported country codes: TR GB DE FR ES IT NL BE AT CH SE NO DK FI PL CZ PT IE GR RO HU BG HR SK RU UA US CA MX BR AR CL CO JP KR CN IN AU NZ SG MY TH ID PH AE SA EG.

excluded_sections ids: cover agency_about chapter_dividers closing brand_overview competitor_map sector_trends sector_swot digital_presence brand_sentiment target_audience google_ads_potential google_ads_campaign google_ads_budget google_ads_competitor google_ads_ad_copy meta_ads_strategy meta_ads_audience meta_ads_tech meta_ads_analysis meta_ads_competitor_analysis meta_ads_creative meta_ads_budget aeo_teaser audit_teaser content_strategy content_strategy_plan seasonality_calendar action_plan roi_projection.

Example

export PITCHKO_API_KEY=...   # your key from Dashboard → Settings → Developers

curl -X POST https://pitchko.io/api/v1/decks \
  -H "Authorization: Bearer $PITCHKO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brand_name": "Acme",
    "brand_url": "https://acme.com",
    "country": "US",
    "language": "en",
    "tone_prefix": "professional",
    "content_density": "detailed",
    "slide_image_source": "pictographic",
    "competitor_urls": ["https://rival.com"]
  }'

Responses

201 Created

{ "id": "f3a1...e9", "status": "pending", "sandbox": true }
Status error Meaning
400 invalid_request Validation failed (see details[] — each has field + message)
401 unauthorized Missing/invalid key
402 insufficient_credits Not enough credits (live only) — see §10
403 not_member / viewer_forbidden Not allowed to create under this agency
400 no_agency No agency could be resolved
429 rate_limited Rate limit or daily cap; see retry_after_seconds
503 service_unavailable Temporary backend issue — retry shortly

4. Poll deck status

A real deck takes around 20 minutes to generate. POST /decks returns immediately with status: "pending"; poll this endpoint until the status is terminal.

GET /decks/{id}

curl https://pitchko.io/api/v1/decks/f3a1...e9 \
  -H "Authorization: Bearer $PITCHKO_API_KEY"

200 OK

{
  "id": "f3a1...e9",
  "status": "completed",
  "brand_name": "Acme",
  "sandbox": false,
  "result": {
    "pdf_url": "https://.../deck.pdf",
    "pptx_url": "https://.../deck.pptx",
    "export_expired_at": null
  },
  "error": null,
  "created_at": "2026-06-22T10:00:00.000Z",
  "completed_at": "2026-06-22T10:19:41.000Z"
}

Status lifecycle

pending → processing → completed
                     ↘ failed
                     ↘ cancelled

export_expired_at (added 2026-09, additive): non-null once Pitchko has measured the export refused by the asset host — the URLs are then dead, do not retry them. null means "not measured gone", not "alive".

Treat the export URLs as impermanent, and note this API cannot refresh them. pdf_url / pptx_url point at the generator's own asset host, not at Pitchko — we do not re-host the file, so their lifetime is not ours to promise. Fetch the bytes when you first see them rather than storing the URL and resolving it later.

Working assumption: do not count on more than ~2 days. Treat that as a ceiling, not a promise — it is our current estimate, not a measured guarantee, and the host is not ours. Measured so far (probe cohort, 2026-08-04): links still alive at 52 hours, and gone by the time anyone looked again at 346 hours (~14 days); the exact boundary sits somewhere in between and we are measuring it. Read 52 hours as a FLOOR — the cohort was still alive when it was checked, so it is the longest lifetime we have observed, not the shortest one that fails. Anything you still need after a couple of days should already be on your side.

There is no re-export endpoint, so a dead link is not a retryable error. Recovery is the dashboard's Regenerate action on that deck (which does not spend a credit) or a fresh POST /decks (which does).

result is null until completed. On failed, error carries a short message.

Status error Meaning
401 unauthorized Missing/invalid key
404 not_found No deck with that id
403 forbidden The deck isn't yours to read

5. AI-visibility (AEO) endpoints

Read-only access to the AI-visibility data your agency already tracks in Pitchko. The endpoints in this section never spend money: they return stored results, and there is no way to start a tracked-brand scan over the API (that is hundreds of live LLM calls against a plan-tiered meter — it stays in the dashboard and the weekly schedule).

The two POST /aeo/grade* endpoints in §6 are the exception on both counts: they take no API key, and the first of them does spend money. They exist to back the embeddable grader widget. If you are building a key-authenticated integration, everything you need is in this section and §6 does not apply to you.

Which agency?

Your key acts as a Pitchko user, and a user can belong to more than one agency.

GET /aeo/brands

Your tracked brands.

curl https://pitchko.io/api/v1/aeo/brands \
  -H "Authorization: Bearer $PITCHKO_API_KEY"
{
  "brands": [
    {
      "id": "a1b2...c3",
      "name": "Acme",
      "domain": "acme.com",
      "industry": "retail",
      "country": "TR",
      "competitor_count": 3,
      "has_prompts": true,
      "created_at": "2026-07-01T00:00:00.000Z"
    }
  ],
  "truncated": false,
  "sandbox": false
}

has_prompts tells you whether the brand is scannable yet. truncated is true when the page filled — raise ?limit= (max 100) or paginate by date.

GET /aeo/brands/{id}/report

The brand's latest completed scan, scored. Add ?include=prompts for the per-prompt-per-engine rows (up to 500; omitted by default because they are most of the payload).

Each prompt row carries mention_excerptadded August 2026 — the short window from the engine's own answer that mentioned: true is based on:

{
  "prompt_id": "p_17",
  "prompt_text": "best crm for small agencies",
  "engine": "chatgpt",
  "mentioned": true,
  "cited": false,
  "sentiment": "positive",
  "competitors_mentioned": ["Rival"],
  "mention_excerpt": "…for smaller teams, Acme is often recommended for its onboarding…"
}

It is null whenever mentioned is false, and null on every report scanned before the field existed — so treat an absent quote as "we have no excerpt for this row", never as "the brand was not mentioned". mentioned remains the flag; the excerpt is evidence for it, not a second copy of it.

Whitespace is collapsed and the window is snapped to word boundaries. A leading or trailing marks where the answer was cut; it is not part of the answer.

curl https://pitchko.io/api/v1/aeo/brands/a1b2...c3/report \
  -H "Authorization: Bearer $PITCHKO_API_KEY"

Abridged:

{
  "report_version": "1.0.0",
  "brand": {
    "id": "a1b2...c3",
    "name": "Acme",
    "domain": "acme.com",
    "country": "TR",
    "language": "tr"
  },
  "run": { "id": "r9...", "status": "complete", "scanned_at": "2026-07-18T09:00:00.000Z" },
  "overall": { "score": 61.4, "grade": "B-" },
  "metrics": { "mention_sov": 0.42, "citation_sov": null, "presence": 0.75, "sentiment": 0.68 },
  "engines": [
    {
      "engine": "chatgpt",
      "score": 72.5,
      "grade": "B+",
      "sample_size": 24,
      "metrics": { "...": null }
    }
  ],
  "engine_failures": [{ "engine": "google-ai-mode", "reason": "quota" }],
  "categories": [{ "category": "purchase_intent", "score": 0, "grade": "F", "sample_size": 4 }],
  "competitors": [
    { "name": "Rival", "domain": "rival.com", "mention_sov": 0.33, "citation_sov": 0.41 }
  ],
  "cited_sources": [
    { "domain": "acme.com", "citations": 14, "is_own_domain": true, "sample_url": null }
  ],
  "cited_sources_truncated": false,
  "recommendations": [
    { "id": "faq_schema", "priority": "high", "leverage": "structured_data", "source": "onsite" }
  ],
  "sandbox": false
}

⚠️ null is not 0

This is the one rule that will break your integration if you get it wrong.

In the example above citation_sov is null and purchase_intent scores a genuine 0. Coercing null to 0 — a ?? 0, a parseFloat, a chart library default — turns "we don't know" into "you scored zero" and reports a failure that never happened. Render nulls as gaps or "not measured".

⚠️ engines_queried is what we TRIED, not what we measured

Added August 2026. engines_queried lists the engines the run attempted. engines carries the ones that produced a score, and engine_failures carries the ones that did not, with a reason key. The two are disjoint and together they account for every entry in engines_queried.

If you render coverage from engines_queried alone, a run in which an engine was unreachable will read as a full-coverage run. That is not hypothetical — it is why the field exists: a production scan advertised five engines, scored three, and said nothing about the other two.

"engines_queried": ["gemini", "chatgpt", "perplexity", "google-ai-mode", "claude"],
"engines":         [ /* gemini, chatgpt, perplexity, claude — 4 scored */ ],
"engine_failures": [{ "engine": "google-ai-mode", "reason": "quota" }]

reason is one of quota · auth · rate_limit · timeout · error · empty. It describes our measurement, never the graded brand — and a failed engine is excluded from overall.score, not folded in as a zero. Same rule as the box above, one level up: an engine we could not reach is a missing measurement, not a bad result.

recommendations[].id is a catalog key, not display text. The localised title, rationale and action resolve at render time, so the stored report stays language-neutral.

A brand that exists but has never completed a scan returns 404 { "error": "no_report" } — distinct from an unknown brand (404 { "error": "not_found" }).

GET /aeo/brands/{id}/history

The trend line: one flat row per completed scan, newest first. This is the BI-friendly shape — it is what the Looker Studio connector reads.

curl "https://pitchko.io/api/v1/aeo/brands/a1b2...c3/history?limit=12" \
  -H "Authorization: Bearer $PITCHKO_API_KEY"
{
  "brand_id": "a1b2...c3",
  "points": [
    {
      "run_id": "r9...",
      "scanned_at": "2026-07-18T09:00:00.000Z",
      "score": 61.4,
      "mention_sov": 42,
      "citation_sov": null,
      "presence": 75,
      "prompt_set_version": 2
    }
  ],
  "sandbox": false
}

Note the units differ from the report: history metrics are 0–100 percentages, the report's are 0–1 ratios. A brand with no completed scans returns 200 with "points": [] — not an error.

prompt_set_version — do not plot across a change

Added 2026-08. A brand's tracked prompt set is editable, and changing it changes what we asked, not how the brand performed. Two points carrying different prompt_set_version values are therefore not comparable: the difference between their scores mixes a real movement with a change of question, and there is no way to separate the two after the fact.

Treat a version boundary as a series break — start a new line, or annotate it. Pitchko's own panel refuses to compute a run-over-run delta across one and says the runs are not comparable instead.

null means the lineage is unknown (a scan from before versioning). It is not 1, and coalescing it to 1 re-creates exactly the false comparison above.

Default limit is 12 (~3 months at the weekly cadence); max 100.

Sandbox behaviour

A pk_test_ key returns a single fictional brand (example.com) with a fixed sample report and a three-point history. It never reads real data, so you can build and ship your integration before paying for a scan. The fixture deliberately includes an unmeasured engine, an unmeasured metric, and a genuine zero — if your parser handles it, it handles production.

Error codes

Status error Meaning
400 ambiguous_agency Multi-agency user; pass agency_id
401 unauthorized Missing/invalid key
403 no_agency The key's user belongs to no agency
403 forbidden Not a member of the requested agency_id
404 not_found Unknown brand (also returned for another agency's brand)
404 no_report Brand exists, but has no completed scan yet

A brand belonging to another agency returns 404, not 403. That is intentional: a distinct 403 would confirm the id is real.


6. Public grader widget (anonymous)

These two endpoints back the embeddable AI-visibility grader — the lead magnet an agency drops on its own site. They are unlike everything above:

§5 AEO endpoints §6 grader endpoints
Auth Bearer API key None — a public widget slug
Scope required aeo:read None — there is no scope to grant
Spends money No YesPOST /aeo/grade does
Caller Your backend An anonymous visitor's browser

Because they are anonymous there is no insufficient_scope here — do not go looking for a scope to add to your key. The caller is identified by the embedding agency's public widget slug, which is safe to ship in a browser: it grants nothing except the ability to run a grade against that agency's own credits, behind a captcha and the abuse caps below.

POST /aeo/grade

Runs a live sweep (5 prompts × 5 engines, N=1) and consumes one AI-visibility widget-scan credit from the embedding agency. The credit is reserved before any engine call — the public surface is the abuse surface, so a check-then-charge would let concurrent requests all read "1 credit left".

curl -X POST https://pitchko.io/api/v1/aeo/grade \
  -H "Content-Type: application/json" \
  -d '{
        "slug": "acme-agency",
        "url": "https://example.com",
        "sector": "outdoor furniture",
        "turnstile_token": "0.AbC..."
      }'

sector is required, and it is what makes the grade mean anything: the two highest-value question categories ask "who are the best X", and with no X the grader degrades to three brand-name lookups.

{
  "run_id": "9f3c...b1",
  "teaser": {
    "brand_name": "Example",
    "domain": "example.com",
    "overall_score": 48.2,
    "overall_grade": "C",
    "engines": [
      { "engine": "chatgpt", "score": 61, "grade": "B-", "invisible": false },
      { "engine": "gemini", "score": 0, "grade": "F", "invisible": true },
      { "engine": "claude", "score": null, "grade": null, "invisible": false }
    ],
    "categories": [{ "category": "category_discovery", "score": 40, "grade": "D" }],
    "counts": {
      "engines_queried": 5,
      "engines_invisible": 1,
      "engines_not_measured": 1,
      "recommendations": 3
    },
    "prompt_count": 5
  },
  "lead_gated": true
}

Read the third engine carefully. score: 0 + invisible: true means measured and never mentioned. score: null means not measured at all — and it is counted under engines_not_measured, never under engines_invisible. The two counts are disjoint; summing them overstates the bad news. Do not coerce null to 0 anywhere in your rendering.

The teaser is deliberately aggregate-only. Everything that says whyrecommendations, prompt_results, cited_sources, competitors — is withheld until the email gate. When lead_gated is false (the agency turned lead capture off), the full report is present on this response instead.

lead_mode decides whether you get a teaser at all (2026-08, BL-2713). In gated and email_first the teaser key is absent — withheld by the server, not merely hidden by our widget. In email_first you must send email on the grade request itself, and the full report comes back on that same response. See §10.

POST /aeo/grade/{id}/lead

The email gate. {id} is the run_id from above. Spends nothing — the scan has already run.

curl -X POST https://pitchko.io/api/v1/aeo/grade/9f3c...b1/lead \
  -H "Content-Type: application/json" \
  -d '{ "slug": "acme-agency", "email": "buyer@example.com", "turnstile_token": "0.XyZ..." }'
{
  "report": {
    "report_version": "1.0.0",
    "brand": { "name": "Example", "domain": "example.com", "country": "TR", "language": "tr" },
    "run": { "id": "9f3c...b1", "scanned_at": "2026-07-25T09:12:44.000Z" },
    "overall": { "score": 48.2, "grade": "C" },
    "metrics": { "mention_sov": null, "citation_sov": 0.2, "presence": 0.6, "sentiment": 0.5 },
    "engines": [],
    "categories": [],
    "cited_sources": [],
    "recommendations": [],
    "prompt_results": []
  },
  "lead_captured": true
}

The captcha token must be fresh — the one spent starting the grade is not reusable. The run id is scoped to the agency resolved from slug and to grader runs only, so one agency's widget can never unlock another agency's run, nor any tracked brand's private scan; anything outside that scope reads as 404 not_found.

The report body is the same shape as §5

report on these two endpoints is the same snake_case body that GET /aeo/brands/{id}/report returns — overall.score, engines, categories, cited_sources, metrics.mention_sov. One parser reads both.

Only the envelope differs, and only where the two callers genuinely differ:

Field §5 key-authenticated §6 grader
brand.id the roster brand's UUID absent — a graded site is not a brand you track
run.status the scan's status absent — a report exists only once the run completed
cost_usd present absent — see below
sandbox present absent — the grader has no sandbox mode

Changed in 2026-07. These two endpoints previously returned the pipeline's internal camelCase shape (overallScore, engineScores, citedSources). They are snake_case now, like every other v1 payload. If you integrated before this change, see the migration note in §10.

Two more grader-specific facts:

Rate limits and abuse caps

Guard Limit Response
Per-IP, both endpoints 5 req / min 429
Per graded domain 2 grades / hour / agency 429 rate_limited, scope: "domain"
Per agency 15 grades / hour 429 rate_limited, scope: "agency"
Unlock attempts per run 5 / hour 429 rate_limited
Request body, both 8 KiB 413 invalid_request

Cap responses carry retry_after_seconds.

Error codes

Status error Meaning
400 invalid_request Bad JSON, missing slug, or failed validation (details)
400 invalid_url URL failed the SSRF allow-list (see reason)
400 invalid_email Malformed email (lead endpoint)
400 disposable_email Throwaway-provider email (lead endpoint)
402 insufficient_credits Embedding agency is out of credits, or the meter is unreadable
403 captcha_failed Turnstile verification failed
404 not_found Unknown widget slug, or no such grader run under this agency
413 invalid_request Body over 8 KiB
429 rate_limited A rate limit or abuse cap tripped
502 scan_failed The sweep failed after starting

402 covers an unreadable meter as well as a genuinely empty one, deliberately: a scan spends money, so the meter fails closed rather than guessing.

On 502 the credit is not refunded — those engine calls were already billed to us. A credit comes back only when the run never started at all, which costs nothing and is invisible to you.


7. Public SEO-audit widget (anonymous)

The SEO sibling of §6: two endpoints backing the embeddable site-audit widget. Same shape of contract — anonymous, identified by the agency's public widget slug, captcha-gated, metered against that agency's own credits, and split into a gated teaser plus an email unlock.

Like §6 they take no API key and no scope, and like §6 they spend the embedding agency's credits — the same wallet its dashboard spends. What stops anonymous traffic draining it is a per-period ceiling on what this surface may spend, applied in the same atomic step as the charge. A 402 does not say which of the two it hit: distinguishing "out of credits" from "cap reached" would let an anonymous caller probe how much the agency has left.

The public surface runs an instant audit only — it never triggers a paid deep crawl.

POST /audit

curl -X POST https://pitchko.io/api/v1/audit \
  -H "Content-Type: application/json" \
  -d '{ "slug": "acme-agency", "url": "https://example.com", "turnstile_token": "0.AbC..." }'
{
  "audit_id": "3b91...7c",
  "teaser": {
    "url": "https://example.com",
    "overall_score": 61,
    "overall_grade": "D",
    "categories": [{ "category": "on_page", "score": 70, "grade": "B-" }],
    "issue_counts": { "total": 12, "error": 3, "warning": 7, "notice": 2 },
    "pages_crawled": 1
  },
  "lead_gated": true
}

The teaser is aggregate-only: it says how many checks failed, never which. Issue keys, affected URLs and the PSI detail are the paywalled half. When lead_gated is false (the agency turned lead capture off), the full report is present on this response instead.

lead_mode decides whether you get a teaser at all (2026-08, BL-2713). In gated and email_first the teaser key is absent. In email_first you must send email on the audit request itself, and the full report comes back on that same response. See §10.

POST /audit/{id}/lead

The email gate. {id} is the audit_id from above. Spends nothing — the audit has already run. Requires a fresh captcha token.

{
  "report": {
    "report_version": "1.0.0",
    "url": "https://example.com",
    "mode": "instant",
    "fetched_at": "2026-07-25T09:12:44.000Z",
    "pages_crawled": 1,
    "estimated_indexed_pages": null,
    "overall_score": 61,
    "categories": [
      {
        "category": "on_page",
        "score": 70,
        "grade": "B-",
        "issue_count": 2,
        "evaluated_checks": 9
      }
    ],
    "issues": [
      {
        "key": "title_missing",
        "category": "on_page",
        "severity": "error",
        "scope": "template",
        "failed_pages": 3,
        "checked_pages": 10,
        "fail_rate": 0.3,
        "sampled_count": 10,
        "affected_urls": ["https://example.com/a"],
        "priority": 12.5,
        "page_type_id": "pt-1"
      }
    ],
    "page_types": [],
    "psi": null,
    "narrative": null,
    "competitor": null
  },
  "lead_captured": true
}

Three things worth reading carefully:

There is no cost_usd on either endpoint, for the same reason as §6: the scan's cost is Pitchko's spend, not a fact about the audited site.

Changed in 2026-07. These endpoints previously returned the pipeline's internal camelCase shape (overallScore, categoryGrades, issueCounts, pagesCrawled) and additionally leaked costUsd. They are snake_case now, like every other v1 payload, and carry no cost field. See §10.

Error codes and abuse caps mirror §6 (invalid_url, captcha_failed, rate_limited with retry_after_seconds, 413 over 8 KiB), with insufficient_credits raised against the embedding agency's credits — the same wallet the grader in §6 spends, and the same one a key spends in §3.

An agency can turn a completed audit into a public link and hand it to the client the audit is about. Two endpoints read it, both anonymous:

Endpoint Returns
GET /audit/shared/{token} the report as JSON
GET /audit/shared/{token}/pdf the agency's branded PDF

The token is the credential. It is 256 bits of randomBytes, base64url, minted per share and unrelated to the audit's id. Treat the URL as a secret: anyone holding it can read the report, which is the entire point of a share link and also the reason both responses carry X-Robots-Tag: noindex and Cache-Control: no-store.

Revocation is permanent. Revoking removes the token, so the old URL never resolves again — re-sharing mints a different one. A link that could come back to life would have been suspended, not revoked.

Every miss is the same 404. Unknown token, revoked token, malformed token and an unreadable row all answer not_found with no further detail. Distinguishing them would answer "does this token exist?" for free, which is the only question worth asking if you are enumerating.

These endpoints spend nothing. Unlike POST /audit, which runs a new scan on the embedding agency's credits, these read a report that was already paid for. There is no meter, no captcha and no insufficient_credits on them — only a per-IP rate limit, tighter on the PDF because a render costs far more than a read.

{
  "url": "https://example.com",
  "mode": "crawl",
  "status": "complete",
  "shared_at": "2026-08-19T10:00:00.000Z",
  "report": { "overall_score": 61, "overall_grade": "D", "...": "as in §7" },
  "branding": {
    "agency_name": "Northwind Digital",
    "logo_url": "https://…/logo.png",
    "primary_color": "#4f46e5",
    "accent_color": "#ec4899",
    "white_label": true
  }
}

8. Integrations

Integration Docs
MCP server https://pitchko.io/docs/mcp
Looker Studio Community connector (Google Apps Script) — ask us for it, it is not self-serve yet

Both authenticate with the same API key described above. The MCP server is read-only and covers three surfaces: the AI-visibility reads, the deck status reads, and — MCP-only today — the agency's SEO-audit reads (list_audits, get_audit_report), all behind the key's scopes (§10).


9. Rate limits

Endpoint Limit
POST /decks 5 req / min
GET /decks/{id} 60 req / min
GET /aeo/brands 60 req / min
GET /aeo/brands/{id}/report 60 req / min
GET /aeo/brands/{id}/history 60 req / min
POST /aeo/grade 5 req / min
POST /aeo/grade/{id}/lead 5 req / min
POST /audit 5 req / min
POST /audit/{id}/lead 5 req / min
POST /api/mcp 60 req / min

Over-limit returns 429 with a Retry-After header. Limits apply per API key — except the anonymous grader (§6) and audit-widget (§7) endpoints, which have no key to attribute to and are limited per IP, then again by their abuse caps (§6, §7).


10. Guarantees

Error code renamed, 2026-08 (insufficient_credits) — read this if you branch on error

Breaking for one value, on three endpoints. The 402 refusal is now insufficient_credits everywhere. It used to have two different names, neither of which described what it actually is.

Was Is now
POST /decks402 insufficient_tokens 402 insufficient_credits
POST /aeo/grade402 insufficient_quota 402 insufficient_credits
POST /audit402 insufficient_quota 402 insufficient_credits
"deck tokens", "widget-scan quota", "audit quota" one credit balance, spent by all three

Nothing else moved. The status is still 402, the envelope is still { "error": …, "message": … }, and every other code on every endpoint is unchanged.

What to change in an existing integration: if you compare error against "insufficient_tokens" or "insufficient_quota", compare against "insufficient_credits". If you branch on the status rather than the code, nothing breaks — 402 has meant "this account cannot pay for the request" since v1 shipped and still does.

Why it changed. Pitchko used to meter three things separately: deck tokens, a widget-scan quota and a widget-audit quota. They are one wallet now, and one wallet cannot honestly answer to three words — an integrator reading insufficient_tokens on POST /decks and insufficient_quota on POST /audit would reasonably conclude that topping one up does not help the other, which is no longer true. Renaming two codes into one is the smallest change that makes the API describe the product.

The message beside the code moved with it and is now "Not enough credits". It is developer-facing detail and not a stable contract — branch on error, not on message. What an end user sees on a public widget did not change: that copy is deliberately neutral ("this tool is temporarily unavailable"), because the empty wallet belongs to the embedding agency and not to the visitor looking at their page.

reason: "quota" inside engine_failures is a different word and is unaffected. It describes a third-party AI engine's own limit during a scan — a measurement of the outside world, not a unit of Pitchko's billing.

Response widened, 2026-08 (prompt_set_version)

Additive — no field changed meaning, none was removed. Every point returned by §5 GET /aeo/brands/{id}/history now carries prompt_set_version.

Was Is now
Every point looked like a measurement of the same thing A point states which version of the brand's tracked prompts it measured
A prompt-set edit was invisible on the wire The version increments, so the break is on the series
A chart drew one continuous line through a change of question A version boundary is a series break — start a new line or annotate

What to change in an existing integration: if you plot the history, do not draw a segment between two points whose prompt_set_version differs. The difference between their scores mixes a real movement with a change of question, and the two cannot be separated after the fact. Nothing breaks if you ignore the field — the line you draw is simply not a measurement of the brand.

null means the lineage is unknown: a scan taken before versioning existed, or one whose prompt set could not be persisted. It is not 1, and coalescing it to 1 re-creates exactly the false comparison above — the same null-vs-zero rule this API applies to metrics, applied to provenance.

Why it was added: a brand's tracked questions are editable, and correcting a brand's sector or market leaves the old questions in place. Pitchko's own panel now refuses to compute a run-over-run delta across such a boundary and says the runs are not comparable instead; withholding the field from the API would have left integrators computing the delta we had just decided was not honest.

Response widened, 2026-08 (engine_failures)

Additive — no field changed meaning, none was removed. Every AI-visibility report body (§5 GET /aeo/brands/{id}/report and §6 POST /aeo/grade, which share one schema) now carries engine_failures.

Was Is now
engines_queried listed 5 engines, engines scored 3, nothing said why engine_failures names the other 2 and gives a reason key for each
An unreachable engine was indistinguishable from one that was never tried engines_queried = enginesengine_failures, always
Coverage could only be inferred from engines_queried, and inferring it lied Coverage is engines; engines_queried is what was attempted

What to change in an existing integration: if you present coverage — "scanned across N engines" — read engines.length, not engines_queried.length. Nothing breaks if you do not, but the number you show will overstate the scan whenever an engine was unreachable.

reason is one of quota · auth · rate_limit · timeout · error · empty, and it describes our measurement rather than the graded brand. A failed engine is excluded from overall.score, never folded in as a 0 — the same null-vs-zero rule as §5, applied to coverage instead of to a metric.

Why it was added: a production scan on 2026-08-08 exhausted a provider's monthly allowance mid-run. Two engines returned nothing, the report advertised five, and both the wire and the UI presented a three-engine measurement as a five-engine one. The gap was structural rather than a bug in one endpoint, which is why the fix is a field on the shared body and not a note in a changelog.

Response narrowed, 2026-08 (lead_mode) — read this if you parse teaser

Breaking for one field. teaser left the required set on AuditCreated and GradeCreated. It is still present for every widget that has not changed its setting, and absent for widgets whose agency picks one of the two new lead modes.

An agency now chooses HOW its public widget asks for an email. The choice is lead_mode, returned alongside lead_gated:

lead_mode Pre-email response carries Email is sent to
teaser teaser POST /{audit,aeo/grade}/{id}/lead
gated nothing POST /{audit,aeo/grade}/{id}/lead
email_first nothing the create request itself
Was Is now
teaser always present present in teaser mode, or whenever report is
lead_gated told you everything about the gate lead_gated = is there a gate; lead_mode = which one
report present iff lead_gated is false also present when an email_first request carried a valid email
The create request took slug, url (+sector) it also accepts email + consent, required in email_first

What to change in an existing integration: treat teaser as optional, and branch on the presence of report rather than on !lead_gated to decide whether you may render the full findings. Both changes are safe against every mode, including the one your widgets run today.

Why teaser had to become conditional rather than be zeroed out. A widget in gated mode promises the visitor sees nothing before handing over an email. Sending the score anyway and asking the client not to draw it makes the gate a UI convention — anyone with devtools reads it out of the network response. The same reasoning already governs issues[] inside the teaser; this applies it one level up. Filling the teaser with zeros was rejected for the reason §5 gives about metrics: a fabricated 0 is a claim, and null/absent is the only honest way to say "withheld".

email_first sends the email on the create request because a Turnstile token is single-use. Splitting it into run-then-submit would force the widget to re-run the challenge between the two calls — invisibly for most visitors, as a second visible challenge for anyone Cloudflare decides to test.

Contract widened, 2026-08 (language)

Additive — every request that was valid before is still valid. language was enum: [tr, en]. It is now a string validated against the deck message packs the deployment ships, so the accepted set grows by adding a pack rather than by changing this contract.

Was Is now
enum: [tr, en], fixed in the schema and in the DB string, validated against messages/deck/*.json at the boundary
A third language needed a migration + a release A third language is one committed file
An unknown code failed schema validation An unknown code returns 400 naming the supported set

What to change in an existing integration: nothing. tr and en are always supported. If you generate a client from the spec, language widens from an enum to a string — a generated enum type may need regenerating, but no value you were sending stops working.

Worth knowing what this is NOT: it is not "any BCP-47 tag is accepted". The server rejects a code it has no deck copy for, because the alternative is a deck whose AI-written body is in your language and whose section headings, chapter dividers and cover are in English. The 400 lists what is available.

Contract tightened, 2026-08 (slide_image_style)

No server behaviour changed. slide_image_style has always been required when slide_image_source is aiGenerated and rejected otherwise — that rule is as old as the field, and a request breaking it has always come back 400 with no deck generated. What changed is that the published contract now says so in a form machines read.

Until now the rule lived only in the field's prose description, so:

Was Is now
slide_image_style declared as a plain optional string Declared with JSON-Schema if/then/else on the request
Codegen produced an optional field with no constraint Codegen can produce the conditional requirement
The rule was discoverable only by reading the sentence Validators and linters enforce it before the request ships

What to change in an existing integration: nothing — unless you generate a client from the spec AND validate locally, in which case a request that was already going to fail server-side now fails in your own validator instead. That is the intended direction: the same rejection, one round-trip earlier.

Listed here rather than passed over because check:api-breaking is right to flag it. A constraint added to a published schema narrows what the contract permits, even when it only catches up to what the server already did, and "the server always behaved this way" is exactly the argument that makes an undocumented tightening feel safe to skip.

Enum narrowing, 2026-08 (slide_text_mode)

slide_text_mode accepted condense and preserve; it now accepts only condense. Sending preserve is a 400.

preserve was not merely unused — it was harmful. Gamma ignores textOptions.amount whenever the mode is preserve (confirmed from the warnings field Gamma returns on the generation request). Since a section runs a ~2,500-character median and a 16:9 slide reads comfortably at 400-800, content_density is the only mechanism that fits deck text onto a slide. A request that set preserve therefore received no error and no warning — just a deck with every card's text rendered whole. Removing the value converts that silent degradation into a rejection.

Nothing to change in an existing integration unless it sent preserve; omit the field, or send condense.

Enum narrowing, 2026-08 (slide_image_source)

slide_image_source accepted eight values; it now accepts three: pictographic, aiGenerated, noImages.

This is a breaking change for a request that sends a retired value — the deck is rejected with 400 before any work starts, per the guarantee above.

Retired value Why Send instead
pexels Stock photography arrived off-brand and off-palette pictographic or aiGenerated
webFreeToUseCommercially Web images pulled third-party marketing collateral with its own typography pictographic or aiGenerated
themeAccent Output quality was a property of the chosen template, not the deck — often unrelated art pictographic
giphy Animated GIFs; wrong register for a sales document, and ~3× the file size pictographic
placeholder Empty frames the recipient must fill by hand noImages

The judgement came from a controlled comparison — one brand, one deck's content, one template, the image source as the only variable.

Decks created before this change keep their stored value. Reading them is unaffected; only new requests are validated against the narrowed enum.

Wire-shape change, 2026-07 (anonymous widget endpoints)

The four anonymous endpoints — POST /aeo/grade, POST /aeo/grade/{id}/lead, POST /audit, POST /audit/{id}/lead — used to return the pipeline's internal camelCase shape. They now return snake_case, matching every key-authenticated endpoint on this API.

This is a breaking change to those four responses. Nothing else moved: the decks and aeo/brands endpoints carry exactly the same fields, with the same names and the same meanings, as before. (include=prompts now places prompt_results before cost_usd rather than after — JSON object order carries no meaning, and no field changed.)

What to change in an existing integration:

Was (camelCase) Is now (snake_case)
report.overallScore report.overall.score (AEO) · report.overall_score (audit)
report.engineScores report.engines
report.categoryGrades report.categories
report.citedSources report.cited_sources
report.promptResults report.prompt_results
metrics.mentionSov metrics.mention_sov
teaser.overallScore teaser.overall_score
teaser.engineGrades teaser.engines
teaser.categoryGrades teaser.categories
teaser.issueCounts teaser.issue_counts
teaser.counts.enginesQueried teaser.counts.engines_queried
report.costUsd (audit) removed — never republished

Why now rather than never: the cost of a split wire format grows with every integrator, and the audit endpoints were additionally publishing our own per-scan spend to anonymous visitors. Both are cheapest to fix while the anonymous surfaces are young.

Key scopes

Every key carries scopes — the surfaces it may reach. A key still acts as its user (all that user's agency, credit and permission rules apply), but it can only reach the surfaces it was minted for.

Scope Grants
decks POST /decks, GET /decks/{id}, and the MCP deck tools (list_decks, get_deck)
aeo:read GET /aeo/* (read-only) and the MCP visibility and SEO-audit tools — the audit tools are MCP-only, no REST twin exists yet

The MCP endpoint (POST /api/mcp) admits a key holding either scope; each tool then enforces its own scope, and tools/list only shows the tools your key can call. See https://pitchko.io/docs/mcp.

Widened 2026-08: aeo:read now additionally unlocks the MCP SEO-audit read tools. This is a deliberate, documented widening of every existing aeo:read key — same agency, same read-only sensitivity class as the visibility data it already granted. If your integration must not see audit data, that separation is tracked as a future audits:read scope split; until it ships, treat aeo:read as "read-only analytics" rather than "AEO only".

The anonymous grader endpoints (§6) sit outside this table entirely — they accept no key, so no scope grants or withholds them.

Pick scopes when you create the key (Dashboard → Settings → Developers). Grant only what the integration needs — a reporting integration wants aeo:read alone, not decks.

Calling a surface your key lacks returns 403, not 401 — the credential is valid, it simply isn't authorised here, so rotating it will not help:

{
  "error": "insufficient_scope",
  "message": "This API key lacks the \"aeo:read\" scope.",
  "required_scope": "aeo:read"
}

Rotating a key preserves its scopes. Rotation replaces a compromised secret; it is not a permission change.

If you have a key from before scopes existed

Keys issued before this change were backfilled to decks only. That was safe because the AI-visibility endpoints had not shipped yet, so no existing key could already have been using them.

If you want an older key to read AI-visibility data, mint a new key with aeo:read — there is deliberately no way to widen an existing key's scopes in place. Widening a live credential should leave a new secret behind it.

There is no wildcard/admin scope, by design.