Imports API
Manages the RSS back catalogue import workflow for self-hosted podcasts. The API parses the source feed once, creates parent podcast_imports and child import_items rows, drives the locked-feed ownership handshake, runs a pre-flight preview step, and dispatches one podcast-imports queue message per episode. The podcast-import-executor worker then mirrors audio + assets to R2 and inserts episodes rows.
Base Path: /api/imports
Authentication: All endpoints require a Bearer token. All podcast-scoped endpoints require the admin role on the target podcast (enforced via requirePodcastRole / requirePodcastRoleByResolver).
Source: src/api/routes/imports/index.ts
Scope & constraints
- Two pipelines share
podcast_importsand thepodcast-importsqueue:- Back-catalogue import (this API). Imports into
hosting_type = 'podcasterplus'podcasts. Locked feeds require an ownership handshake. - Hosting migration (
is_hosting_migration = true). Imports intohosting_type = 'external'podcasts, merging audio onto existing episodes byexternal_guidand creating new episodes for unmatched RSS items; on terminal completion the worker flipshosting_typetopodcasterplus. Driven by the External Link API — this back-catalogue API does not handle migration commits.
- Back-catalogue import (this API). Imports into
- At most one in-flight import per podcast. Status in (
preview,pending,running,verification_required) counts as in-flight — a second start request returns409with the existingimportId. The same uniqueness lock covers hosting migrations via the partial unique indexidx_podcast_imports_hosting_migration_active. - Locked feeds (
<podcast:locked>yes</podcast:locked>) require ownership verification before any episode is dispatched (back-catalogue only — migrations skip this because the podcast already owns the feed in the system).
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /podcast | Start import; parse feed, stage items, branch on lock status. |
POST | /:id/verify-ownership | Validate the 6-digit magic code emailed to the owner. |
POST | /:id/recheck-feed | Re-parse feed; detect unlock / token / email pivot. |
POST | /:id/resend-code | Issue and email a fresh 6-digit code. |
POST | /:id/confirm | Apply pre-flight selection (limit + detected ep numbers) & dispatch. |
GET | /:id | Load the import + items for the progress UI. |
POST | /:id/cancel | Flip import to canceled; worker skips pending messages. |
POST | /:id/retry-failed | Reset all failed items to pending and re-enqueue. |
POST | /:id/items/:itemId/retry | Reset a single failed item and enqueue one message. |
POST | /:id/resume | Re-dispatch pending items from a canceled import. |
All responses follow the project standard:
// success
{ success: true, data: { /* route-specific */ } }
// failure
{ error: 'Human-readable message', code?: 'MACHINE_CODE' }Status model
podcast_imports.status values drive the UI and executor behaviour:
| Status | Meaning |
|---|---|
verification_required | Locked source feed; awaiting email code or feed recheck. |
preview | Feed parsed; awaiting user pre-flight confirmation. |
pending | Dispatched but no item has claimed yet (transient). |
running | Queue is draining; executor is actively processing items. |
completed | All items reached a terminal state with zero failures. |
partial | Terminal state — some items failed, some succeeded. |
failed | Terminal state — every item failed. |
canceled | User cancelled; queue skips remaining pending items. |
import_items.status is a parallel enum (pending, processing, completed, failed, skipped_duplicate, skipped_oversize, skipped_canceled, skipped_user_limit).
Start an import
POST /api/imports/podcastParses the source feed, backfills channel-level verification fields on the podcast row (Apple verify token, AI disclosure, <podcast:txt purpose="verify"> tokens), inserts the parent podcast_imports row and one import_items row per unique GUID. Pre-marks items whose GUID already exists on the target podcast as skipped_duplicate.
Request:
{
"podcast_id": "uuid",
"feed_url": "https://example.com/feed.rss"
}Branching on lock status:
Responses:
200/preview— unlocked feed, ready for preview UI.json{ "success": true, "data": { "importId": "uuid", "status": "preview", "totalItems": 23, "detection": { /* EpisodeNumberDetection */ } } }200/verification_required(email_code) — code emailed, user enters it.json{ "success": true, "data": { "importId": "uuid", "status": "verification_required", "method": "email_code", "totalItems": 23, "ownerEmail": "d***@example.com" } }200/verification_required(feed_recheck) — no reachable owner email; UI shows the three-path fallback (unlock / email visibility / paste verify tag).json{ "success": true, "data": { "importId": "uuid", "status": "verification_required", "method": "feed_recheck", "totalItems": 23, "verificationToken": "ppv-<uuid>" } }400— parse failure / empty feed / invalid URL (code fromparseRssFeedFull).409— an import is already in-flight for this podcast;importIdreturned for client to redirect.504— feed fetch timed out.
Ownership verification
Tier 1 — email magic code
POST /api/imports/:id/verify-ownership
Body: { "code": "123456" } // exactly 6 digitsCalls validate_import_ownership_code RPC. On success, flips status to preview; on failure returns 400 with code equal to the RPC's error_code (e.g. INVALID, EXPIRED, LOCKED_OUT).
POST /api/imports/:id/resend-codeReissues a code via create_import_ownership_code. Rate-limiting is enforced inside the RPC; a 429 response includes rate_limit_reset_at.
Tier 2 — feed recheck
POST /api/imports/:id/recheck-feedRe-fetches the source feed (cache-busted via ?_ppv=<ts>) and auto-detects which of three user actions took effect, in priority order:
- Unlocked —
<podcast:locked>is no longeryes. Clearsis_locked_sourceand transitions topreview. - Token match — the row's
verification_tokenappears in a<podcast:txt purpose="verify">tag. Transitions topreview. - Email pivot — an owner email has appeared (either on
<podcast:locked owner="…">or<itunes:owner/email>). Persists the address, issues a code, sends the email, and flipsmethodtoemail_code. - Still stuck — returns
400 STILL_LOCKEDwith a hint to try another path at the source host.
Status codes: 400 for NOT_APPLICABLE (status already past verification) or STILL_LOCKED; 504 for fetch timeout.
Pre-flight preview & confirm
POST /api/imports/:id/confirm
Body: {
"applyDetectedEpisodeNumbers": true | false,
"limit": number | null
}Called from the preview page once the user reviews the detected episode-number pattern and picks an import limit. Applies the selection via apply_import_preview_selection RPC which atomically:
- Marks items beyond the limit as
skipped_user_limit(held). - Patches
parsed_payload.episodeNumberon the selected items from the cacheddetection.perGuidmap (only whenapplyDetectedEpisodeNumbers = true). - Bumps
skipped_counton the parent row.
Then loads remaining pending items, dispatches one queue message per item (spaced ~0.25s to be polite to the source host, capped at 900s), and transitions to running.
Terminal-on-confirm edge case: when every item was either a pre-existing duplicate or held by the limit, there's nothing for the queue to process. The route transitions directly to completed / partial / failed based on counters, since the worker can't finalise what it never sees.
Failure modes:
400 NOT_IN_PREVIEW— import is not inpreviewstatus.400 INVALID_SELECTION—applyDetectedEpisodeNumbers=truebut no pattern was detected.500 DISPATCH_FAILED— queue send failed. Status is rolled back topreviewso the user can retry without losing the held-item marking.
Success:
{
"success": true,
"data": { "status": "running", "selectedCount": 18, "heldCount": 5 }
}Read import state
GET /api/imports/:idReturns the parent row (with redacted locked_owner_email) plus every import_items row ordered by created_at. Used by the progress page on first render; live updates come from Supabase Realtime subscriptions in the UI.
Cancel, retry, resume
POST /api/imports/:id/cancelFlips status to canceled from any non-terminal state. Items currently in processing finish naturally (worker doesn't check cancel mid-stream); items still on the queue get ack'd and skipped when the worker re-fetches the parent.
POST /api/imports/:id/retry-failedResets every failed item back to pending and re-dispatches. For a previously canceled import, sweeps the full pending set — pre-existing pending items lost their original queue messages to the cancel skip path and need re-dispatch. On dispatch failure, reverts the parent to canceled (preserving the Resume UX).
POST /api/imports/:id/items/:itemId/retrySingle-item retry, atomic via retry_import_item RPC (resets item + decrements failed_count in one transaction to avoid parallel-retry races). Rejects items in skipped_user_limit — that's an "extend import" semantic, not a retry.
POST /api/imports/:id/resumeOnly valid on canceled imports. Two sub-cases:
started_at IS NULL— canceled from the preview step. Restores status topreview; no queue dispatch (the confirm endpoint will dispatch).started_atset — was partway through running. Loads pending items and re-dispatches.
On dispatch failure, rolls back to canceled via the shared markDispatchFailed helper so the Resume button stays available.
Queue message format
The Hono route is the sole producer for the podcast-imports queue. Message schema:
interface PodcastImportMessage {
type: 'import_item';
import_id: string; // podcast_imports.id
import_item_id: string; // import_items.id
podcast_id: string;
}Consumer configuration in workers/podcast-import-executor/wrangler.toml:
max_batch_size = 1— each message is a long-running stream; batching would block the batch on the slowest download.max_concurrency = 4— polite to the source host.max_retries = 5with exponential backoff + jitter (capped at 15 min/attempt).dead_letter_queue = "podcast-imports-dlq".
See the Podcast Import Executor worker doc for per-item pipeline details.
RPC surface
The route delegates several transactional paths to SQL functions (all SECURITY DEFINER):
| RPC | Called from | Purpose |
|---|---|---|
create_import_ownership_code | POST /podcast, /recheck-feed, /resend-code | Issue a 6-digit code; enforce rate limits. |
validate_import_ownership_code | POST /:id/verify-ownership | Validate, mark consumed, transition row. |
apply_import_preview_selection | POST /:id/confirm | Mark held items, patch episode numbers, bump counters. |
retry_import_item | POST /:id/items/:itemId/retry | Reset item + decrement failed_count atomically. |
Environment bindings
| Binding | Type | Purpose |
|---|---|---|
PODCAST_IMPORTS_QUEUE | Queue<PodcastImportMessage> | Per-episode job dispatch. |
RESEND_API_KEY | string | Ownership code email sending. |
RESEND_FROM_EMAIL | string | Email sender address. |
When PODCAST_IMPORTS_QUEUE is unbound the route throws HTTPException(500, 'Import queue is not configured') — dispatch is never attempted silently.
TypeScript client usage
import { createApiClient } from '$api/client';
import { createClient } from '$lib/supabase/client';
const supabase = createClient();
const {
data: { session }
} = await supabase.auth.getSession();
const token = session?.access_token!;
const client = createApiClient(fetch);
// Start
const res = await client.api.imports.podcast.$post(
{ json: { podcast_id, feed_url } },
{ headers: { Authorization: `Bearer ${token}` } }
);
// Verify email code
await client.api.imports[':id']['verify-ownership'].$post(
{ param: { id: importId }, json: { code: '123456' } },
{ headers: { Authorization: `Bearer ${token}` } }
);
// Re-check feed
await client.api.imports[':id']['recheck-feed'].$post(
{ param: { id: importId } },
{ headers: { Authorization: `Bearer ${token}` } }
);
// Confirm pre-flight selection
await client.api.imports[':id'].confirm.$post(
{ param: { id: importId }, json: { applyDetectedEpisodeNumbers: true, limit: 50 } },
{ headers: { Authorization: `Bearer ${token}` } }
);Related
- Podcast Import Executor worker — queue consumer, per-item streaming pipeline.
- External Link API — hosting-migration commit endpoint (the other producer of
podcast-importsmessages). - External-to-Internal Hosting Migration Guide — end-to-end Epic 11 flow.
- RSS API — cache invalidation endpoint used by the executor on completion.
- RSS Import — Cost Baseline — measured per-episode cost.
- Source:
src/api/routes/imports/index.ts - UI entry:
src/routes/(app)/p/[slug]/import/new/+page.svelte