---
title: "Start an app capture: its presigned upload URL."
method: POST
path: "/story-projects/{projectId}/captures"
tags: ["Sources"]
---

# Start an app capture: its presigned upload URL.

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

## Request

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

A capture is an MP4 screen recording of a web app: H.264, constant 30 fps, at most 1920×1080, tagged bt709 (its sound, when it has any, in AAC), up to 512 MB. Every file is checked after its upload; one that does not conform is rejected with the command that re-encodes it. The file never passes through the API: create answers a presigned PUT URL that works for an hour (send exactly the declared bytes with `content-type: video/mp4`), then finalize checks the stored file and marks the capture `ready` or `rejected` (with `rejectionReason`). Free. The answer's `upload` is where to PUT the file (with `upload.headers`); `eventsUpload` and `checkpointUploads` are the recorder's side files when declared. Then POST `/story-projects/{projectId}/captures/{captureId}/finalize`. A capture names the stored tour it ran (`tourId`, one of this project's tours: its feature is copied onto the capture) and the product feature it shows (`featureId`, a product Bible episode id — alone, or exactly the tour's own feature); `featureName` on every read is the feature's name in the Bible as it reads now. Unknown fields are refused with 400 `validation_failed`. Requires `Idempotency-Key`.

## Headers

- `Idempotency-Key` string, required

## Request body

- AppCaptureCreate
  - `bytes` integer, required: The file's exact size in bytes; the upload URL is signed for it.
  - `fileName` string
  - `label` string
  - `sha256` string: The file's SHA-256; finalize rejects a stored file that does not match.
  - `originUrl` string, uri: The page the recording was made on.
  - `route` string: The app route the recording starts on.
  - `appVersion` string: Free text: the app's version or image tag when it was recorded.
  - `tour` object: The recorder's tour, as it ran (at most 65536 bytes of JSON).
  - `tourId` string: The stored tour whose run this recording is: one of this project's tours. Its feature is copied onto the capture (or send `featureId` with it, exactly the tour's own feature).
  - `featureId` string: The product feature it shows (a product Bible episode id): alone, or exactly the feature the named `tourId` walks through.
  - `events` AppCaptureSizedFile
    - `bytes` integer, required
  - `checkpoints` AppCaptureSizedFile[]: One PNG still per tour step, in step order.
    - `bytes` integer, required
  - `role` 'raw' | 'edit': `raw` for a complete uncut master (private, never played by a beat); `edit` (default) for a derivative or hand upload.
  - `sourceCaptureId` string: For an edit: the ready raw master in this project it was cut from.
  - `editMap` object: For an edit: the frame-based source-to-edit map (kept and removed ranges, both hashes); checked at create and finalize.
  - `timing` AppCaptureSizedFile
    - `bytes` integer, required

## Response `200`

The capture (`uploading`) and where to upload its file.

- AppCaptureCreateResult
  - `capture` AppCapture, required
    - `id` string, required
    - `projectId` string, required
    - `status` 'uploading' | 'ready' | 'rejected', required: `uploading` until finalize checks the file; then `ready`, or `rejected` with `rejectionReason`. An `uploading` capture whose verified file could not be saved stays `uploading` (retryable) and also carries `rejectionReason`.
    - `role` 'raw' | 'edit', required
    - `sourceCaptureId` string, nullable, required
    - `editMap` object, nullable, required
    - `hasTiming` boolean, required
    - `tourId` string, nullable, required: The stored tour (a Tour row) whose run this recording is, when the capture names one; null after the tour is deleted.
    - `featureId` string, nullable, required: The product feature (a Series Bible episode id) this capture shows. Kept when the Bible drops it.
    - `featureName` string, nullable, required: The feature's name in the Bible as it reads now; null when `featureId` is null or the Bible no longer lists it.
    - `featureSource` 'tour' | 'writer' | 'matched', nullable, required: How `featureId` was set: `tour` (copied from the tour it ran), `writer` (named at import, or chosen or confirmed by the writer) or `matched` (tagged by a strong match the writer has not confirmed yet). Null when it names no feature.
    - `suggestedFeature` AppCaptureFeatureSuggestion, required: The feature a capture most likely shows, matched from its name, page and tour against each feature's name, pages and tours.
      - `featureId` string, required
      - `featureName` string, required
      - `confidence` 'high' | 'medium' | 'low', required: `high` is a strong match (tagged by itself); `medium` a likely one (offered to the writer); `low` a weak guess.
      - `reason` string, required: In plain words: what in the recording names the feature.
    - `smoothness` AppCaptureSmoothness, required: How smoothly the recording machine drew the take. A take is only as smooth as the machine that recorded it: on a machine with no graphics card a page with heavy effects (blur, glass, blended gradients) is drawn only a few times a second, so the recording steps instead of moving while its sound plays on. Every take measures how fast its machine could draw the page (never how many new pictures a still page painted, which is legitimately few) and marks the stretches drawn slower than 24 fps as stuttering.
      - `verdict` 'smooth' | 'stutters', required: `stutters` when any stretch was drawn too slowly. Re-take it on a GPU runner: record the same tour on a machine with a graphics card (a Linux box with an NVIDIA GPU: `sleeperhit capture record <tour.json> --gpu nvidia`, or a Mac), import the new take, and play it instead.
      - `ownSoundLufs` number, nullable, required: How loud the take's own sound plays, LUFS, measured once when it was recorded and stored on the take beside its smoothness; null when nobody measured it (a hand upload, or a take recorded before the measurement), never the same as silence.
      - `paintedFps` number, required: New pictures painted a second over the take. Low on a still page, so never judged on its own.
      - `drawFps` number, nullable, required: How fast the recording machine could draw the page (requestAnimationFrame callbacks a second, median).
      - `slowestDrawFps` number, nullable, required: The slowest draw rate measured anywhere in the take.
      - `stutterSpans` object[], required: Where the picture stutters, in ms of THIS capture (an edit carries its raw master's through its cuts).
        - `startMs` number, required
        - `endMs` number, required
        - `drawFps` number, required
        - `paintedFps` number, required
      - `gpu` object, nullable, required: The recording machine's graphics as Chrome reported them.
        - `compositing` string, nullable, required
        - `renderer` string, nullable, required
        - `flags` string, nullable, required: `nvidia-egl` when the recorder launched Chrome on the NVIDIA GPU (--gpu nvidia); null for none.
    - `rejectionReason` string, nullable, required: Every way the file falls short of a capture, and the ffmpeg command that re-encodes it. Null unless the capture could not be saved: a `rejected` capture carries why, and an `uploading` one carries why its verified file could not be stored (still retryable).
    - `label` string, nullable, required
    - `fileName` string, nullable, required
    - `bytes` integer, required: The stored size once finalized; the declared size before.
    - `sha256` string, nullable, required: The stored file's SHA-256, once finalized.
    - `durationMs` integer, nullable, required
    - `fps` number, nullable, required
    - `width` integer, nullable, required
    - `height` integer, nullable, required
    - `hasAudio` boolean, nullable, required: The file carries sound you can hear: an audio stream that is not silence. Null until finalized.
    - `soundSpans` object[], nullable, required: Where the capture's sound is heard (louder than -45 LUFS), in ms on its own timeline: a take recorded with `sound: app` carries the app's own sound (a table read, a player). Null when it has no sound.
      - `startMs` integer, required
      - `endMs` integer, required
    - `originUrl` string, nullable, required
    - `route` string, nullable, required
    - `appVersion` string, nullable, required
    - `tour` object, nullable, required
    - `hasEvents` boolean, required: The recorder's event log was uploaded with it.
    - `checkpointCount` integer, required: How many per-step stills were uploaded with it.
    - `playbackUrl` string, uri, nullable, required: While `ready`: a link to watch it, until `playbackUrlExpiresAt` (read again for a new one).
    - `playbackUrlExpiresAt` string, date-time, nullable, required
    - `uploadExpiresAt` string, date-time, required
    - `createdAt` string, date-time, required
    - `finalizedAt` string, date-time, nullable, required
  - `upload` AppCaptureUploadTarget, required: Where to PUT one file: its exact declared bytes, with these headers (the type and length are signed into the URL).
    - `url` string, uri, required
    - `method` 'PUT', required
    - `headers` object, required
    - `expiresAt` string, date-time, required
  - `eventsUpload` AppCaptureUploadTarget, required: Where to PUT one file: its exact declared bytes, with these headers (the type and length are signed into the URL).
    - `url` string, uri, required
    - `method` 'PUT', required
    - `headers` object, required
    - `expiresAt` string, date-time, required
  - `timingUpload` AppCaptureUploadTarget, required: Where to PUT one file: its exact declared bytes, with these headers (the type and length are signed into the URL).
    - `url` string, uri, required
    - `method` 'PUT', required
    - `headers` object, required
    - `expiresAt` string, date-time, required
  - `checkpointUploads` object[], required
    - `url` string, uri, required
    - `method` 'PUT', required
    - `headers` object, required
    - `expiresAt` string, date-time, required
    - `index` integer, required
  - `next` string, required: What to do next, in a sentence.

## 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/64c1102ccb2f?raw)
