---
title: "Attach a source to a project."
method: POST
path: "/story-projects/{projectId}/sources"
tags: ["Sources"]
---

# Attach a source to a project.

`POST /story-projects/{projectId}/sources`

## Request

- Base URL: `https://docs.sleeperhit.studio/api/v1`
- URL: `https://docs.sleeperhit.studio/api/v1/story-projects/{projectId}/sources`
- Auth: HTTP bearer

Accepts inline `text`, `markdown`, URL, and PDF sources. Requires `Idempotency-Key` for safe retries. Check `/api/v1/capabilities.sources.types` before sending a type. Set `metadata.sourceContextMode` to `full` when planning and final artifact writing must receive the complete integrity-verified retained text instead of the normal digest + preview; full mode fails rather than clipping. A producer that sends one source per item names it with `producer` + `externalId`; a repeat of an item the project already holds returns that source with `deduplicated: true` instead of storing it again.

## Headers

- `Idempotency-Key` string, required

## Request body

- StorySourceCreate: Inline `text` and `markdown` require `content`. `url` and `pdf` require `uri` and extract asynchronously — poll the source until `status: READY` or `FAILED`. PDF sources require the upstream Content-Type to be `application/pdf`; `url` sources require a text-y Content-Type.
  - `type` 'text' | 'markdown' | 'url' | 'pdf', required: Source payload type. Inline `text` and `markdown` are accepted synchronously; `url` and `pdf` extract asynchronously through the source worker.
  - `content` string: Inline payload for `text` and `markdown` types. Max 4,000,000 bytes (UTF-8). Retained text is normally capped at 1,500,000 characters.
  - `uri` string, uri: URL for `url` and `pdf` sources. Must be http or https; SSRF-blocked addresses (private, link-local, cloud metadata, multicast) are rejected.
  - `label` string
  - `retentionPolicy` 'EXTRACTED_ONLY' | 'STORE_ORIGINAL' | 'METADATA_ONLY': How long the API persists source content. `EXTRACTED_ONLY` (default) stores the extracted text and metadata. `STORE_ORIGINAL` keeps the raw bytes too. `METADATA_ONLY` drops extracted text after the digest pass.
  - `producer` string: For a program that sends one source per item it covers: the producer's name (e.g. `hackernewsradio`). Send with `externalId`, or neither. The `integration:` prefix is reserved. A producer's items are episode material, not canon: Bible generation never reads them, and a season pins one only when it was curated into that season (items sent here never are). A full-context producer item is not digested: `digestStatus: SKIPPED` ("Used in full by its episode plan.") is terminal and by design.
  - `externalId` string: The producer's id for this item (for `hackernewsradio`, the numeric Hacker News story id). A project holds one live source per `producer` + `externalId`: a repeat returns that source with `deduplicated: true`, including one still `PENDING`/`EXTRACTING`. Delete the source to send the item again (and delete one that never becomes READY).
  - `originatedAt` string, date-time: When the source material itself was made, not when it is attached: a thread's posting time, an article's publication time. ISO 8601; at most a day in the future. Stored as `metadata.originatedAt` and returned as `originatedAt`. A project with `seasonCadence: "monthly"` files the episode made from this source under that month (UTC); without it, the source's `createdAt` is used.
  - `metadata` object: Freeform source metadata. Reserved policy: `sourceContextMode: "full"` makes both StoryPlan generation and final artifact writing consume the complete hash-verified retained text. Full mode requires EXTRACTED_ONLY or STORE_ORIGINAL retention and fails instead of clipping above 1,500,000 characters. `sourceProducer` is refused: send the top-level `producer` and `externalId`. `originatedAt` here is held to the same rule as the top-level field, and must agree with it when both are sent.

## Response `200`

The created source, or the live source already holding this item (`deduplicated: true`).

- StorySourceCreateResult
  - `source` StorySource, required
    - `id` string, required
    - `projectId` string, required
    - `type` 'TEXT' | 'MARKDOWN' | 'URL' | 'PDF', required
    - `label` string, nullable, required
    - `uri` string, nullable, required
    - `status` 'PENDING' | 'EXTRACTING' | 'READY' | 'FAILED', required
    - `retentionPolicy` 'EXTRACTED_ONLY' | 'STORE_ORIGINAL' | 'METADATA_ONLY', required: How long the API persists source content. `EXTRACTED_ONLY` (default) stores the extracted text and metadata. `STORE_ORIGINAL` keeps the raw bytes too. `METADATA_ONLY` drops extracted text after the digest pass.
    - `byteSize` integer, nullable, required
    - `tokenEstimate` integer, nullable, required
    - `contentHash` string, nullable, required
    - `extractedTextPreview` string, nullable, required
    - `extractedObjectKey` string, nullable, required
    - `rawObjectKey` string, nullable, required
    - `failureCode` string, nullable, required
    - `failureMessage` string, nullable, required
    - `producer` string, nullable, required: Who made this item (`hackernewsradio`, or `integration:<integrationId>` for an item kept from a connected product); null for material a person attached. Producer items are episode material, not canon: Bible generation never reads them, and a season pins one only when it was curated into that season.
    - `externalId` string, nullable, required: The producer's id for this item. Null once the source is deleted or FAILED, which frees the item to be sent again.
    - `metadata` object, nullable, required
    - `originatedAt` string, date-time, nullable, required: When the source material itself was made (`metadata.originatedAt`), e.g. a thread's posting time; null when the producer did not say. A project with `seasonCadence: "monthly"` files the episode made from this source under that month, falling back to `createdAt`.
    - `digest` StorySourceDigest, required: Structured digest of a single source. Always source-grounded; the model is instructed not to invent claims that the source does not make.
      - `summary` string, required: 3-6 sentence plain-prose summary.
      - `thesis` string, required: Single sentence capturing the source's dominant point.
      - `claims` object[], required
        - `statement` string, required
        - `kind` 'fact' | 'estimate' | 'opinion' | 'forecast' | 'quotation' | 'definition', required
        - `attribution` string, nullable, required
      - `evidence` object[], required
        - `claimIndex` integer, required
        - `support` string, required
      - `gaps` string[], required
      - `rightsNotes` string[], required
    - `digestStatus` 'NOT_STARTED' | 'PENDING' | 'RUNNING' | 'READY' | 'FAILED' | 'SKIPPED', required: Lifecycle of the post-extraction LLM digest. `PENDING` with digestFailureCode `provider_capacity_backoff` means Sleeper Hit scheduled a delayed retry. `SKIPPED` is terminal (stop polling) and is not a fault; the underlying source remains usable. It means either the digest could not run (sidecar/provider missing or no extracted text), or, for a full-context producer item (`producer` set and `metadata.sourceContextMode: "full"`), that it is skipped by design (`digestFailureCode: digest_skipped`, `digestFailureMessage: "Used in full by its episode plan."`) because its plan reads every character.
    - `digestFailureCode` string, nullable, required
    - `digestFailureMessage` string, nullable, required
    - `digestedAt` string, date-time, nullable, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `extractedAt` string, date-time, nullable, required
  - `deduplicated` boolean, required: True when the project already held a live source for this `producer` + `externalId`: that source is returned and nothing new is stored.

## Other responses

- `400`: Request payload failed validation. The error message names the offending field.
- `401`: Missing, malformed, revoked, or expired API key.
- `403`: API key is valid but lacks the scope, is blocked by IP allowlist, or the account is disabled.
- `404`: The named project or source does not exist or is owned by another account.
- `413`: Source content is larger than the documented inline limit.
- `422`: Requested source type is not yet supported. See `/api/v1/capabilities.sources.types`.
- `429`: Rate limit for this API key + IP + route family was exceeded.
- `500`: The API failed unexpectedly. Safe to retry with the same Idempotency-Key.

---

[API](https://skmtc.dev/sleeperhit/apis/sleeper-hit-studio-story-api.md) · [All operations](https://skmtc.dev/sleeperhit/apis/sleeper-hit-studio-story-api/llms.txt) · [OpenAPI document](https://skmtc.dev/sleeperhit/apis/sleeper-hit-studio-story-api/revisions/64c1102ccb2f?raw)
