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.
- Base URL:
https://pitchko.io/api/v1 - Auth:
Authorization: Bearer <api_key> - Content type:
application/json - This guide, hosted: https://pitchko.io/docs/guide
- Interactive reference: https://pitchko.io/docs/api (rendered from the machine-readable contract at https://pitchko.io/openapi.json — point codegen and linters there; that URL is the single source of truth for the wire shape)
- MCP server guide: https://pitchko.io/docs/mcp
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.
- Create a Pitchko account (free) at https://pitchko.io/signup, or sign in to an existing one.
- Go to Dashboard → Settings → Developers and create a key.
- Turn Sandbox on for a
pk_test_key. Start here — it is free and charges nothing (§2). - Pick the scopes the integration needs (§10):
decksto create and read decks,aeo:readfor 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 · noImages — narrowed 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 | condense — narrowed 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.nullmeans "not measured gone", not "alive".Treat the export URLs as impermanent, and note this API cannot refresh them.
pdf_url/pptx_urlpoint 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 |
Recommended polling
- A deck takes ~20 minutes — poll every 20–30 s, don't poll faster than every 5 s.
- Use a longer interval after the first few minutes (e.g. 20 s → 45 s → 60 s).
- Give up after ~30 minutes and treat as failed.
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.
- One agency (the usual case): nothing to do.
- Several agencies: pass
?agency_id=<uuid>. Omitting it returns400 { "error": "ambiguous_agency" }rather than picking one for you — a silent default would make the same request return different agencies' data depending on row order.
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_excerpt — added 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
}
⚠️
nullis not0This is the one rule that will break your integration if you get it wrong.
0— the brand was queried and never mentioned. A real, bad score.null— the metric was not measured (that engine returned nothing scorable, or the signal is unavailable there at all).In the example above
citation_sovisnullandpurchase_intentscores a genuine0. Coercingnullto0— a?? 0, aparseFloat, 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_queriedis what we TRIED, not what we measuredAdded August 2026.
engines_queriedlists the engines the run attempted.enginescarries the ones that produced a score, andengine_failurescarries the ones that did not, with a reason key. The two are disjoint and together they account for every entry inengines_queried.If you render coverage from
engines_queriedalone, 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" }]
reasonis one ofquota·auth·rate_limit·timeout·error·empty. It describes our measurement, never the graded brand — and a failed engine is excluded fromoverall.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 | Yes — POST /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 why —
recommendations, 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:
runs_per_promptis always1here (the tracked-brand scan uses 3). The report says which N it used, so nothing is passed off as more precise than it is.competitorsis always empty. An anonymous visitor supplies no competitor set, somention_sovstaysnull— not measured — rather than being fabricated from an assumed field of one.- There is no
cost_usd. What the scan cost is Pitchko's spend, not a fact about the graded brand. (GET /aeo/brands/{id}/reportdoes returncost_usd— that caller is the agency being metered for it.)
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:
pages_crawledis a sample size, never a site total. Issues reportfailed_pagesout ofchecked_pageswith afail_rate. We do not know how many pages your site has and will not pretend to.issues[].keyis a catalog key, not copy. The label, the explanation and the fix resolve from the catalog at render time, so the payload stays language-neutral.nullis not0. A category withscore: nullwas not evaluated; a category withscore: 0was evaluated and failed everything.
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 leakedcostUsd. 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.
Shared audit links (anonymous, link-authenticated)
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
}
}
reportisnullwhile the audit is running, and if it failed. An agency may share a link before the crawl finishes. You get an honestnull, never a fabricated empty report.brandingisnullonly when the agency row could not be read. It never falls back to white-label — the free presentation is the safe failure.- No
cost_usd, noagency_id, no auditid, at any depth. The agency's lead-capture settings (cta_text,headline,lead_mode, …) are absent too: you are the client, not the funnel. - The PDF is written in the owning agency's language, not yours. It is one
document about one site for one client, so there is no
?lang=.
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
- All platform rules apply to your key's account: credit balance, daily limits, and permissions are enforced exactly as in the Pitchko app — a key can never exceed its owning account's limits or reach another account's data.
- Unexpected input never generates a deck. Out-of-range selections and
unknown fields are rejected with a
400before any work starts. - Two endpoints spend anything:
POST /decksandPOST /aeo/grade. Everything reachable with anaeo:readkey — the §5 endpoints and the MCP server — is read-only and starts no scan. The grader endpoint in §6 is not key-authenticated at all; it draws on the embedding agency's credits, and no API key can reach it or be billed for it.
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 /decks → 402 insufficient_tokens |
402 insufficient_credits |
POST /aeo/grade → 402 insufficient_quota |
402 insufficient_credits |
POST /audit → 402 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 = engines ∪ engine_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:readnow additionally unlocks the MCP SEO-audit read tools. This is a deliberate, documented widening of every existingaeo:readkey — 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 futureaudits:readscope split; until it ships, treataeo:readas "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.