---
title: "Update a story project."
method: PATCH
path: "/story-projects/{projectId}"
tags: ["Projects"]
---

# Update a story project.

`PATCH /story-projects/{projectId}`

## Request

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

Partial update. Only the supplied fields are modified. `metadata` is MERGED into what is stored, never written over it: a key you send is set, a key you send as null is removed, a key you leave out is kept, and `metadata: null` removes every key you can set. `metadata.seasons` (the project's named seasons) is kept whatever you send, and a patch that names it is refused with 400 `validation_failed` — create a season with POST /story-projects/{projectId}/seasons and move an episode with PATCH /scripts/{scriptId} `seasonNumber`. Pass `archived: true` to archive (or `false` to unarchive). `brandLogo` sets the project's brand logo (`{ base64, contentType? }` or `{ sourceUrl }`: a PNG, JPEG or WebP of up to 2 MB and 4096 pixels on a side, decoded by the server and stored as its own clean copy on the studio's media host) or, as null, removes it; the project returns its address as `brandLogoUrl`, and a closing sequence can end on it (`logoCard`). A malformed logo is refused with 400 `validation_failed`. `workspaceRole` (`writer` | `creator` | `business`) moves the project to that role's list in the sidebar's switcher; it never changes the project's `kind`. A `kind: product` change from a Screenwriter account is refused with 403 `product_kind_not_for_role`.

## Request body

- StoryProjectPatch
  - `name` string
  - `description` string, nullable
  - `metadata` object, nullable: Your own JSON for cross-reference, MERGED into what is stored: a key you send is set, a key sent as null is removed, a key left out is kept; null removes every key you can set. Keys the platform reads: `coverageCalibrations` (up to 5 names, e.g. `["comedy"]`) opts this project's screenplay coverage into the series coverage calibrations an admin has written (a name with no active calibration is ignored); `seasonCadence` (`"monthly"`) files new episodes by month. A malformed value is refused. `seasons` is the project's season list and is refused here (POST /story-projects/{projectId}/seasons names one).
  - `archived` boolean
  - `seasonCadence` 'monthly', nullable: Set (`"monthly"`) or clear (null) how new episodes are filed into seasons — the same as `metadata.seasonCadence`, keeping every other metadata key. Not combined with `metadata`. Only episodes made afterwards are filed by it.
  - `kind` 'story' | 'product': Change what this project is. Refused with 409 `project_kind_locked` once the project has production (scripts, plans, jobs, captures or releases); refused with 409 `project_kind_conflict` when it would invalidate a saved rubric report, unless `confirmKindChange: true`.
  - `confirmKindChange` boolean: Accept that changing `kind` invalidates the saved rubric report that scored the current Bible. Send with `kind` after reading 409 `project_kind_conflict`.
  - `contentGuard` boolean: Turn the ages-13-and-up content guard on or off for everything written for the project from now on.
  - `medium` 'audio' | 'video': Change what the story is made as: `audio` saves the Series Bible's `productionMedium: audio`; `video` on an audio project sets it back to undecided for the Bible to settle. Not combined with `kind`. Refused for a product project and, while the project is recurring, for `video`.
  - `recurring` boolean: True makes the project a recurring show (its show is made, or its audio series becomes one, not set up and off). False makes its show an ordinary series again (one nothing was set up or made for is removed); refused while the show is on (turn it off first) or while an outside producer makes it under a standing approval (revoke that first). A recurring show is audio for now: make it a podcast (`medium: audio`) to make it recurring.
  - `workspaceRole` 'writer' | 'creator' | 'business': A project's `workspaceRole` can be changed after it is made (`writer`, `creator` or `business`; whoever may change the project's settings): it moves the project to that role's list in the sidebar's switcher and changes nothing else. The project's `kind` (story or product) never follows it.
  - `clickSound` object, nullable: A project's click sound is the product's own interface click, played on every press in every screen beat of the project's trailers instead of the studio's stock click: a sound from My Library (`librarySoundId`, that sound's id — import the product's own file there first), with `trimMs` (0–2000) skipping silence at its start and `gainDb` (-24 to +12) levelling it against the stock click once; each click plays at most 400 ms of it. Sent as `clickSound` on the project update, or null to go back to the stock click. A sound that is gone, or too short once trimmed, plays the stock click and the beat says why. Free; the next render of each trailer plays it.
    - `librarySoundId` string, required: The My Library sound every press plays (`GET /library/audio` answers its id); refused when it is not in your My Library or not on the studio's media host.
    - `trimMs` number: Milliseconds of silence skipped at its start (0 when left out).
    - `gainDb` number: dB over (or under) each click's own level, set once (0 when left out).
  - `brandLogo` union: A project's brand logo is its own mark: a PNG, JPEG or WebP image of up to 2 MB and 4096 pixels on a side (a transparent PNG on a dark ground shows best), sent as `brandLogo` on the project update — `{ base64, contentType? }` with the file's bytes, or `{ sourceUrl }`, a public https address it is fetched from once. The server decodes it and stores its own clean copy on the studio's own media host (a PNG stays a PNG; a JPEG becomes a WebP), so a file that does not decode as the image it says it is is refused; `brandLogo: null` removes it. The project read returns its address as `brandLogoUrl`. A deck's or a trailer's closing sequence can end on it (`logoCard: true`): the closing's save copies the project's logo at that moment, so after replacing the logo, save the closing again to show the new one. Free.
    - object
      - `base64` string, required: The image file's bytes as base64, or a data:image/...;base64 URL (at most 2097152 bytes decoded, and 4096 pixels on a side).
      - `contentType` 'image/png' | 'image/jpeg' | 'image/webp': The image type; the file's own bytes decide it when left out, and a type that differs from them is refused.
    - object
      - `sourceUrl` string, uri, required: A public https address of the logo image, fetched once (private addresses refused).

## Response `200`

Updated project envelope.

- StoryProjectEnvelope
  - `project` StoryProject, required
    - `id` string, required
    - `name` string, required
    - `description` string, nullable, required
    - `metadata` object, nullable, required
    - `kind` 'story' | 'product', required: What this project is: `story` (characters, seasons, episodes; judged on premise and series engine) or `product` (personas, features, truth; judged on the product Bible). It selects the coverage rubric and the Bible's shape, and once the project exists it is the project's own: it never follows the account. A create with no `kind` takes the account's role default: `product` for a Business account, `story` otherwise; the ChatGPT app always starts a `story`.
    - `workspaceRole` 'writer' | 'creator' | 'business', nullable, required: Every project is stamped with the workspace role it was made under (`writer`, `creator` or `business`): the creator's current role, or the role the create names. The sidebar's project list shows the current role's projects first, with one click to all of them. Null for a project made before roles were stamped, which shows under every role.
    - `seasonCadence` 'monthly', nullable, required: How a new episode is filed into a season. `monthly`: the season named for the month (UTC) its source material was made (`originatedAt` on the source, else when the source was added, else when the episode was made), created after the last season when the month is new. Null: numbered seasons (the season the plan, its season run or its Series Bible entry names, else Season 1). Stored as `metadata.seasonCadence`.
    - `medium` 'audio' | 'video', nullable, required: What the story is made as: `audio` (a podcast or audio drama: voices, music and sound, no picture) or `video` (for the screen). Read from the Series Bible's `productionMedium` (`audio` is audio, any visual medium is video); null until the Bible says.
    - `recurring` boolean, required: A recurring show: new episodes on a schedule from its sources. True when the project's show is made by Sleeper or by an outside producer under a standing approval.
    - `showSeriesId` string, nullable: The show's publishing series: the id `GET /publishing-series/{seriesId}/show` (MCP get_show, `sleeperhit show get`) takes. Null when the project is not a show.
    - `contentGuard` boolean, required: Keeps every script, plan and Series Bible written for the project suitable for ages 13 and up: no strong profanity, no graphic violence, no sexual content. On for a project started from ChatGPT; off otherwise unless set.
    - `brandLogoUrl` string, nullable, required: The project's brand logo on the studio's media host, or null. A project's brand logo is its own mark: a PNG, JPEG or WebP image of up to 2 MB and 4096 pixels on a side (a transparent PNG on a dark ground shows best), sent as `brandLogo` on the project update — `{ base64, contentType? }` with the file's bytes, or `{ sourceUrl }`, a public https address it is fetched from once. The server decodes it and stores its own clean copy on the studio's own media host (a PNG stays a PNG; a JPEG becomes a WebP), so a file that does not decode as the image it says it is is refused; `brandLogo: null` removes it. The project read returns its address as `brandLogoUrl`. A deck's or a trailer's closing sequence can end on it (`logoCard: true`): the closing's save copies the project's logo at that moment, so after replacing the logo, save the closing again to show the new one. Free.
    - `clickSound` object, nullable, required: The project's own click sound, or null for the stock click. A project's click sound is the product's own interface click, played on every press in every screen beat of the project's trailers instead of the studio's stock click: a sound from My Library (`librarySoundId`, that sound's id — import the product's own file there first), with `trimMs` (0–2000) skipping silence at its start and `gainDb` (-24 to +12) levelling it against the stock click once; each click plays at most 400 ms of it. Sent as `clickSound` on the project update, or null to go back to the stock click. A sound that is gone, or too short once trimmed, plays the stock click and the beat says why. Free; the next render of each trailer plays it.
      - `librarySoundId` string, required: The My Library sound it plays.
      - `label` string, required: The sound's name in My Library when it was picked.
      - `url` string, required: The sound's file on the studio's media host.
      - `trimMs` number, required: Milliseconds skipped at its start.
      - `gainDb` number, required: dB over each click's own level.
      - `durationMs` number, nullable, required: Its length, when My Library knows it.
    - `archivedAt` string, date-time, nullable, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `sourceCount` integer
    - `yourRole` 'owner' | 'editor' | 'viewer': The caller's place on the project: `owner`, or `editor` / `viewer` on a product project shared with them (GET /story-projects/{projectId}/members). An editor changes the project's content but cannot delete it, archive it or manage its members; a viewer reads.
    - `show` ProjectListShow
      - `seriesId` string, required: The show's publishing series (`showSeriesId`).
      - `state` 'not_set_up' | 'off' | 'on' | 'paused' | 'external', required: The show's state: `on`, `off`, `paused`, `not_set_up`, or `external` (an outside app makes it).
      - `timezone` string, required: The show's clock (IANA), for its days and times.
      - `publishedEpisodes` integer, required: Episodes published in the show's feed (episodes only, never trailers or extras).
      - `nextRunAt` string, date-time, nullable, required: When the next scheduled run makes episodes. Null when the show is not on or nothing is scheduled.
      - `next` string, nullable, required: `nextRunAt` in words, in the show's clock: "tonight 7 pm Central".
      - `latestEpisode` object, nullable, required: The newest published episode, or null before the first.
        - `releaseId` string, required: The feed release (GET /publishing-releases/{releaseId}).
        - `scriptId` string, nullable, required: The script the episode was made from, when the release names one.
        - `href` string, nullable, required: The episode's page in the app (a path on the studio's site), when it has a script.
        - `code` string, nullable, required: Its number: "S4E429" in a season, "Ep 12" without one.
        - `title` string, required
        - `durationMs` integer, nullable, required: Its audio length, when known.
        - `publishedAt` string, date-time, required

## 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.
- `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/86e084144cef?raw)
