# Podnivo API integration guide

This is the canonical, agent-readable guide for integrating Podnivo into an application. Use it together with the live OpenAPI document for exact request and response schemas.

## Authoritative sources

- Integration guide: https://api.podnivo.com/ai/INTEGRATION.md
- OpenAPI 3 contract: https://api.podnivo.com/v3/api-docs
- Interactive API reference: https://api.podnivo.com/scalar
- Human documentation: https://api.podnivo.com/docs
- Agent file index: https://api.podnivo.com/ai/llms.txt

The OpenAPI contract is authoritative for paths, parameters, request bodies, response bodies, and status codes. If this guide and the OpenAPI contract differ, follow OpenAPI. Do not invent fields or operations when a source cannot be reached; ask the developer to provide the contract.

## Quickstart

Set these server-side environment variables:

```dotenv
PODNIVO_BASE_URL=https://api.podnivo.com
PODNIVO_API_KEY=pb_live_your_key
```

Send the key as a Bearer token:

```bash
curl --get 'https://api.podnivo.com/v1/search' \
  --data-urlencode 'q=climate' \
  --data-urlencode 'type=podcast' \
  --data-urlencode 'sort=relevance' \
  --data-urlencode 'limit=20' \
  --header 'Accept: application/json' \
  --header "Authorization: Bearer $PODNIVO_API_KEY"
```

`pb_live_` credentials are server keys. Never put one in browser JavaScript, a mobile app, a desktop bundle, source control, logs, analytics, or an AI prompt. Public clients must use a short-lived listener token minted by the application's own backend.

## Server-side TypeScript example

```ts
const baseUrl = process.env.PODNIVO_BASE_URL ?? 'https://api.podnivo.com'
const apiKey = process.env.PODNIVO_API_KEY

if (!apiKey) throw new Error('PODNIVO_API_KEY is required')

export async function searchPodcasts(query: string, after?: string) {
  const url = new URL('/v1/search', baseUrl)
  url.searchParams.set('q', query)
  url.searchParams.set('type', 'podcast')
  url.searchParams.set('sort', 'relevance')
  url.searchParams.set('limit', '20')
  if (after) url.searchParams.set('after', after)

  const response = await fetch(url, {
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    signal: AbortSignal.timeout(10_000),
  })

  if (!response.ok) {
    const problem = await response.json().catch(() => null)
    throw new Error(`Podnivo ${response.status}: ${problem?.detail ?? 'request failed'}`)
  }

  return response.json() as Promise<{
    items: Array<{ type: string; id: string; title: string }>
    page: { limit: number; hasMore: boolean; next?: string }
  }>
}
```

Use the project's existing HTTP client, secret loader, logging conventions, and error types instead of adding a parallel stack. Set bounded connect and request timeouts.

## Search and suggestions

### Search

```http
GET /v1/search?q={query}&type={all|podcast|episode}&sort={relevance|newest}&limit={1..100}&after={opaque_cursor}
```

The response contains `items` and `page`. A search item can contain `type`, `id`, `score`, `title`, `author`, `imageUrl`, `language`, `publishedAt`, `podcastId`, and `durationSeconds`. Fields that do not apply to an item type can be absent or null.

When `page.hasMore` is true, send `page.next` unchanged as the next request's `after` value. Never decode, edit, sort, persist assumptions about, or construct a cursor. Treat podcast IDs and episode IDs as opaque strings.

### Autocomplete suggestions

```http
GET /v1/search/suggestions?q={prefix}&type={all|podcast|episode}&limit={1..20}
```

Debounce interactive calls, cancel stale requests, require a useful prefix in the UI, and do not let an older response replace a newer query's results.

## Public API route directory

Use the live OpenAPI document for exact parameters and schemas.

### Catalog and discovery

```http
GET    /v1/search
GET    /v1/search/suggestions
GET    /v1/podcasts/{id}
GET    /v1/podcasts/{id}/episodes
GET    /v1/episodes/{id}
GET    /v1/discover
GET    /v1/categories
POST   /v1/podcasts/resolve
POST   /v1/podcasts/batch
POST   /v1/podcasts/submit
GET    /v1/podcasts/{id}/health
```

### Listener identity and state

```http
POST   /v1/user-tokens
PUT    /v1/users/{userId}/subscriptions/{podcastId}
DELETE /v1/users/{userId}/subscriptions/{podcastId}
GET    /v1/users/{userId}/subscriptions
GET    /v1/users/{userId}/feed
GET    /v1/users/{userId}/history
GET    /v1/users/{userId}/recommendations
GET    /v1/users/{userId}/home
PUT    /v1/users/{userId}/progress/{episodeId}
POST   /v1/users/{userId}/opml/import
GET    /v1/users/{userId}/opml/export
GET    /v1/users/{userId}/export
DELETE /v1/users/{userId}
```

### Change delivery and usage

```http
GET    /v1/changes
POST   /v1/webhooks
GET    /v1/webhooks
DELETE /v1/webhooks/{id}
POST   /v1/alerts
GET    /v1/alerts
GET    /v1/alerts/{id}/matches
DELETE /v1/alerts/{id}
GET    /v1/usage
```

### Transcripts

```http
GET    /v1/episodes/{episodeId}/transcript
GET    /v1/transcripts/search
```

## Scopes

Server keys can be limited to these scopes:

- `catalog:read`
- `catalog:submit`
- `listener:token`
- `listener:read`
- `listener:write`
- `changes:read`
- `webhook:manage`
- `alerts:manage`
- `usage:read`

Request only the scopes the application needs. A `403` usually means the authenticated credential is valid but lacks the required scope or does not match the requested listener.

## Listener tokens for public clients

Mint a token from the trusted application backend using a server key with `listener:token`:

```http
POST /v1/user-tokens
Authorization: Bearer pb_live_server_key
Content-Type: application/json

{"externalUserId":"user_123"}
```

The response includes `accessToken`, `tokenType`, `expiresAt`, and `externalUserId`. Listener tokens are tenant-bound, limited to listener read/write scopes, and restricted to the matching external user. Token lifetime defaults to 15 minutes and is bounded between 60 and 3,600 seconds by the service configuration.

Use a stable, non-sensitive application user ID. Do not use an email address, access token, or raw personal identifier as `externalUserId`. The `{userId}` path value must match the token's `externalUserId` when a listener token is used.

## Playback progress and conflicts

```http
PUT /v1/users/{userId}/progress/{episodeId}
Content-Type: application/json

{
  "positionSeconds": 1842,
  "completed": false,
  "version": 7
}
```

`positionSeconds` is required. Send the last known `version` when updating shared listener state. On `409 Conflict`, fetch fresh state, reconcile the user's newer action, and retry with the current version. Do not overwrite a conflict blindly. Preserve an `ETag` returned by the API when the application's cache layer supports it.

## Errors, retries, and quotas

Error responses use `application/problem+json` and can contain:

```json
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "The request could not be processed",
  "instance": "/v1/search",
  "code": "validation_error",
  "errors": {}
}
```

Handle these classes deliberately:

- `400`: fix request input; do not retry unchanged.
- `401`: missing, invalid, or expired credential; refresh a listener token where applicable.
- `403`: wrong scope, tenant, or listener binding.
- `404`: resource does not exist or is not visible to this tenant.
- `409`: optimistic concurrency conflict; refetch and reconcile.
- `429`: rate or monthly quota reached; honor `Retry-After` when present.
- `5xx` or network timeout: transient service/upstream failure; fail safely.

Use bounded exponential backoff with jitter only for operations that are safe to retry. `GET`, `PUT`, and `DELETE` are normally retryable when the request body and preconditions are unchanged. Do not blindly retry `POST` requests. Cap attempts and total elapsed time.

Quota information can be returned in `X-RateLimit-Limit`, `X-MonthlyLimit-Limit`, and `X-MonthlyLimit-Remaining`. Do not expose internal keys or full upstream error bodies to public clients.

## Webhooks

Supported catalog event names are:

```text
podcast.created
podcast.updated
podcast.deleted
episode.created
episode.updated
episode.deleted
```

Webhook deliveries include:

```http
Podcastbase-Signature: t={unix_timestamp},v1={hex_digest}
```

Verify `v1` as the hexadecimal HMAC-SHA256 of:

```text
{timestamp}.{raw_request_body}
```

Use the webhook signing secret returned at registration, the exact raw request bytes, constant-time digest comparison, and a narrow timestamp tolerance. Reject invalid or stale signatures. Deduplicate deliveries by event `id`, commit work transactionally, and return a `2xx` only after the event is safely recorded. Never log the signing secret or an unredacted sensitive payload.

## Publisher data and privacy

- Publisher feeds remain the source of truth for catalog data.
- Audio URLs point to publisher-controlled media. Do not copy or redistribute audio without explicit rights.
- Only use transcript content returned by Podnivo. Availability is permission-aware and can change.
- Keep tenant and listener data isolated in caches, database rows, metrics, and logs.
- Do not log API keys, listener tokens, webhook secrets, full listener exports, or complete request/response bodies containing listener data.
- Implement user export and deletion flows when the product stores or exposes listener state.

## Integration completion checklist

Before calling an integration complete:

1. Read the live OpenAPI operation and schemas for every endpoint used.
2. Keep the server key outside public bundles and mint listener tokens server-side.
3. Preserve opaque IDs and cursors exactly.
4. Use bounded timeouts and intentional retry rules.
5. Handle validation, authentication, authorization, not-found, conflict, quota, and upstream failure paths.
6. Verify webhook signatures and idempotency when webhooks are enabled.
7. Add focused contract tests for success and failure behavior.
8. State whether verification ran against a mock, local service, or the live API; never claim live verification without evidence.
