{"openapi":"3.0.0","info":{"title":"PodKit API","version":"0.1.0","description":"Podcast data API — search, podcast + episode metadata, chapters, and transcripts. Authenticate with an API key in the `x-api-key` header."},"components":{"schemas":{"Podcast":{"type":"object","properties":{"id":{"type":"number","example":1200361736},"title":{"type":"string"},"artist":{"type":"string","nullable":true},"artworkUrl":{"type":"string","nullable":true},"feedUrl":{"type":"string","nullable":true},"description":{"type":"string","nullable":true}},"required":["id","title","artist","artworkUrl","feedUrl","description"]},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]},"Episode":{"type":"object","properties":{"id":{"type":"number","description":"PodKit episode id. Pass it to GET /v1/episode/{id} and its /chapters and /transcript routes.","example":8842},"guid":{"type":"string"},"title":{"type":"string","nullable":true},"pubDate":{"type":"string","nullable":true},"audioUrl":{"type":"string","nullable":true},"duration":{"type":"string","nullable":true},"description":{"type":"string","nullable":true}},"required":["id","guid","title","pubDate","audioUrl","duration","description"]},"Chapter":{"type":"object","properties":{"startTime":{"type":"number"},"title":{"type":"string","nullable":true},"image":{"type":"string","nullable":true},"url":{"type":"string","nullable":true}},"required":["startTime","title","image","url"]},"TranscriptSegment":{"type":"object","properties":{"startTime":{"type":"number"},"endTime":{"type":"number","nullable":true},"speaker":{"type":"string","nullable":true},"text":{"type":"string"}},"required":["startTime","endTime","speaker","text"]},"Usage":{"type":"object","properties":{"plan":{"type":"string","example":"starter"},"limit":{"type":"number","description":"Monthly request quota for the plan.","example":10000},"current":{"type":"number","description":"Requests used so far this calendar month.","example":3200},"remaining":{"type":"number","description":"limit − current, floored at 0.","example":6800},"resetsAt":{"type":"string","description":"UTC start of next month (quota reset).","example":"2026-08-01T00:00:00.000Z"},"rateLimit":{"type":"object","properties":{"perMinute":{"type":"number","description":"Per-minute burst limit for the plan.","example":30}},"required":["perMinute"]}},"required":["plan","limit","current","remaining","resetsAt","rateLimit"]},"CreateKeyResponse":{"type":"object","properties":{"id":{"type":"string"},"apiKey":{"type":"string"},"ownerEmail":{"type":"string"},"plan":{"type":"string"}},"required":["id","apiKey","ownerEmail","plan"]},"CreateKeyRequest":{"type":"object","properties":{"ownerEmail":{"type":"string","format":"email"},"plan":{"type":"string","minLength":1}},"required":["ownerEmail"]}},"parameters":{}},"paths":{"/v1/search":{"get":{"tags":["Search"],"summary":"Search podcasts by keyword","description":"Checks the search cache (24h), else queries the iTunes Search API. If iTunes is rate-limited/unavailable, falls back to Podcast Index. Results are cached and the `source` field reports which upstream served them (cache | itunes | podcastindex).","parameters":[{"schema":{"type":"string","minLength":1,"example":"history"},"required":true,"name":"q","in":"query"}],"responses":{"200":{"description":"Matching podcasts","content":{"application/json":{"schema":{"type":"object","properties":{"query":{"type":"string"},"source":{"type":"string","enum":["cache","itunes","podcastindex"]},"count":{"type":"number"},"results":{"type":"array","items":{"$ref":"#/components/schemas/Podcast"}}},"required":["query","source","count","results"]}}}},"400":{"description":"Invalid query parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Both iTunes and Podcast Index rate-limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Both upstreams unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/podcast/{itunesId}":{"get":{"tags":["Podcasts"],"summary":"Get a podcast with a page of episodes","description":"Resolves the podcast (cache → iTunes Lookup), backfills a missing description from Podcast Index, and returns a paginated, newest-first episode list parsed from the RSS feed.","parameters":[{"schema":{"type":"string","pattern":"^\\d+$","example":"1200361736"},"required":true,"name":"itunesId","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":20,"example":20},"required":false,"name":"limit","in":"query"},{"schema":{"type":"integer","nullable":true,"minimum":0,"default":0,"example":0},"required":false,"name":"offset","in":"query"}],"responses":{"200":{"description":"Podcast with episodes","content":{"application/json":{"schema":{"type":"object","properties":{"podcast":{"$ref":"#/components/schemas/Podcast"},"episodeCount":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"},"hasMore":{"type":"boolean"},"episodes":{"type":"array","items":{"$ref":"#/components/schemas/Episode"}}},"required":["podcast","episodeCount","limit","offset","hasMore","episodes"]}}}},"400":{"description":"Invalid id or pagination params","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Podcast not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"iTunes rate limit reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"iTunes or RSS feed unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/episode/{id}":{"get":{"tags":["Episodes"],"summary":"Get an episode","parameters":[{"schema":{"type":"string","pattern":"^\\d+$","example":"42"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Episode with parent podcast","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"number"},"guid":{"type":"string"},"title":{"type":"string","nullable":true},"pubDate":{"type":"string","nullable":true},"audioUrl":{"type":"string","nullable":true},"duration":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"podcast":{"type":"object","properties":{"id":{"type":"number"},"title":{"type":"string","nullable":true},"artist":{"type":"string","nullable":true}},"required":["id","title","artist"]}},"required":["id","guid","title","pubDate","audioUrl","duration","description","podcast"]}}}},"400":{"description":"Invalid episode id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Episode not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/episode/{id}/chapters":{"get":{"tags":["Episodes"],"summary":"Get an episode’s chapters","description":"Fetches and normalizes the episode’s <podcast:chapters> JSON. On a fetch/parse failure, returns chapters: null with the raw sourceUrl and parseError: true.","parameters":[{"schema":{"type":"string","pattern":"^\\d+$","example":"42"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Normalized chapters (or graceful fallback)","content":{"application/json":{"schema":{"type":"object","properties":{"episodeId":{"type":"number"},"chapters":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/Chapter"}},"sourceUrl":{"type":"string"},"parseError":{"type":"boolean"}},"required":["episodeId","chapters"]}}}},"400":{"description":"Invalid episode id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Episode or chapters not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/episode/{id}/transcript":{"get":{"tags":["Episodes"],"summary":"Get an episode’s transcript","description":"Picks the best <podcast:transcript> (JSON > VTT > SRT > text), fetches and normalizes it. On a fetch/parse failure, returns null fields with sourceUrl and parseError: true.","parameters":[{"schema":{"type":"string","pattern":"^\\d+$","example":"42"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Normalized transcript (or graceful fallback)","content":{"application/json":{"schema":{"type":"object","properties":{"episodeId":{"type":"number"},"format":{"type":"string","enum":["json","vtt","srt","text"]},"text":{"type":"string","nullable":true},"segments":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/TranscriptSegment"}},"sourceUrl":{"type":"string"},"parseError":{"type":"boolean"}},"required":["episodeId","format","text","segments"]}}}},"400":{"description":"Invalid episode id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Episode or transcript not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/usage":{"get":{"tags":["Usage"],"summary":"Check current plan usage (free — does not count against your quota)","description":"Returns the API key's current plan, monthly request quota, requests used this calendar month, remaining requests, the UTC quota-reset timestamp, and the per-minute burst limit. This call is read-only and free: it does NOT increment your usage counter and is not subject to the per-minute burst limit.","responses":{"200":{"description":"Current usage for the authenticated key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Usage"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/admin/keys":{"post":{"tags":["Admin"],"summary":"Issue an API key","description":"Gated by the `x-admin-secret` header. Generates a new key, stores only its hash, and returns the raw key ONCE — it can never be retrieved again.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateKeyRequest"}}}},"responses":{"201":{"description":"The newly created key (shown once)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateKeyResponse"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid admin secret","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Key creation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Key store not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}