Skip to content
API / Scans API
  1. Docs
  2. API
  3. Scans API

Scans API

Scan a space through the API: create the space, declare a video or photos, upload them, start the scan and follow it with server-sent events or webhooks.

Open .md

A scan goes through four calls: create, upload, start, then follow it (an event stream, polling, or webhooks). The SDK does all of it in one call, sc.scan().

Terminal
API=https://api.spacecontext.app/v1
AUTH="Authorization: Bearer $SPACECONTEXT_API_KEY"

1. Create a space

Terminal
curl -X POST $API/spaces -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"name": "Seaside flat", "space_type": "apartment"}'
JSON
{ "object": "space", "id": "spc_3f…", "source": "api", "livemode": true, "name": "Seaside flat", "space_type": "apartment", "description": null, "external_id": null, "created_at": "…", "updated_at": "…" }

external_id (up to 128 characters) lets you store your own reference, for example a listing id.

An account creates at most 200 spaces and 500 scans a day in each mode, test keys included; past that, 429 RATE_LIMIT_EXCEEDED with Retry-After until midnight UTC and the allowance in daily_limit. A retry with the same external_id or Idempotency-Key returns what was already created and does not count again.

2. Declare the scan

For a video, declare its type and exact size: up to 4 GB on api.spacecontext.app, and limits.max_video_bytes of GET /v1 states the limit of the deployment you call (below):

Terminal
curl -X POST $API/spaces/spc_3f…/scans -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: flat-2026-10-05" \
  -d '{"input": {"kind": "video", "language": "en", "video": {"content_type": "video/mp4", "content_length": 48211983}}}'

content_type is the file’s container:

content_typeContainerRead inside it
video/mp4, video/quicktime, video/x-m4v, video/3gppMP4, MOV, M4V, 3GPH.264, HEVC (8 and 10 bit), AV1, ProRes, MPEG-4 part 2, H.263; AAC, Opus, PCM, AMR or no audio; fragmented MP4 too
video/webm, video/x-matroska, video/matroskaWebM, Matroska (.mkv)VP9 or H.264; Opus or AAC

Any frame rate is fine, and a phone’s rotation is applied: a portrait video gives upright frames. Any other type (AVI, MPEG-TS, Ogg, FLV, WMV…) is refused here with 400 INVALID_CONTENT_TYPE, before anything is created or charged; convert it to MP4 first: ffmpeg -i in.avi -c:v libx264 -c:a aac -movflags +faststart out.mp4.

The duration is read from the container itself, whatever type you declared (for a fragmented MP4 that does not state it, from the video), and a video over 10 minutes fails with VIDEO_TOO_LONG, refunded. A WebM recorded in a browser with MediaRecorder states no duration: where the API reads videos with its own ffmpeg (limits.max_video_bytes of GET /v1 above 100 MB), its length is measured and it is read like any other video; elsewhere its scan fails with VIDEO_UNREADABLE, refunded. There, remux it first, which writes the duration: ffmpeg -i in.webm -c copy out.webm.

For photos, list them in order, with their time in milliseconds when you know it:

JSON
{ "input": { "kind": "frames", "language": "it", "frames": [{ "timestamp_ms": 0 }, { "timestamp_ms": 2000 }] } }

language is the language the report is written in, en by default:

CodeLanguage
enEnglish
itItaliano
esEspañol
frFrançais
deDeutsch
ptPortuguês
nlNederlands

The narration can be in any language: a video narrated in Spanish can have its report in English.

Either can carry a transcript ({ "text": "…", "segments": [{ "start_ms": 0, "end_ms": 1500, "text": "This is the kitchen." }] }) when you already have one; for videos without it, the audio is transcribed for you.

The answer is the scan, with upload instructions:

JSON
{
  "object": "scan", "id": "scn_8a…", "status": "awaiting_upload",
  "upload": {
    "kind": "video", "method": "PUT",
    "url": "https://…",
    "headers": { "Content-Type": "video/mp4", "Content-Length": "48211983" },
    "expires_at": "…"
  }
}

3. Upload

Video: PUT the bytes to upload.url with upload.headers. A video can be up to 4 GB and 10 minutes. When the URL points to storage (a signed link, valid for an hour), do not send your API key; only this link takes a video over 95 MB. When it points to the API (…/v1/scans/{id}/video, up to 95 MB, in upload.max_bytes), send the Authorization header as usual.

Terminal
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --upload-file flat.mp4

--upload-file sends the file from disk as it goes, with its length; --data-binary @flat.mp4 would read all of it into memory first.

GET /v1 states the limits of the deployment it answers for in limits (max_video_bytes, max_video_seconds, max_photos). A content_length over max_video_bytes is refused when the scan is declared (400 VALIDATION_ERROR, with the limit in the message), before anything is uploaded or charged. A deployment that does not state its limits takes videos up to 100 MB.

The Content-Type of the upload must be the declared type: another one is refused with 415 INVALID_CONTENT_TYPE. The Content-Length must be the declared size: through the API another one is refused with 400 VALIDATION_ERROR, and the storage link, which signs the length, refuses it too.

Size. A phone’s default recording fits: ten minutes at 1080p (16–17 Mbps) come to about 1.2 GB, and in 4K at 30 fps to about 3 GB. On a slow connection, compress before uploading (a scan needs no more than about 8 Mbps); the CLI does it with --compress when ffmpeg is installed, and by itself for a video over the limit.

Photos: one PUT per photo to upload.url_template with {index} replaced, starting at 0:

Terminal
curl -X PUT $API/scans/scn_8a…/frames/0 -H "$AUTH" -H "Content-Type: image/jpeg" --data-binary @frame-0.jpg

Uploads can be repeated. A scan not started within 24 hours is cancelled and its uploads deleted.

4. Start

Terminal
curl -X POST $API/scans/scn_8a…/start -H "$AUTH" -H "Content-Type: application/json" -d '{}'

202 with the scan queued and its charge: { "kind": "free_scan" | "scan_charge", "price_cents": 100, "refunded": false }. The scan is charged now, atomically; see Billing.

AnswerMeaning
202Started
200Already started; nothing charged again
409 UPLOAD_INCOMPLETESome photos or the video are missing
429 SCANS_IN_PROGRESSTen scans of the account are already processing; start again when one finishes
402 INSUFFICIENT_FUNDSAdd funds (automatic top-up, if enabled, starts now)
402 SPENDING_CAP_REACHEDThe key’s monthly cap is reached

5. Follow

Terminal
curl -N $API/scans/scn_8a…/events -H "$AUTH"      # live, as Server-Sent Events
curl $API/scans/scn_8a… -H "$AUTH"                 # or one look

status moves through queued, processing (with stage: ingesting, screening, observing, fusing, enriching, saving, verifying) to complete, or failed with error and charge.refunded: true.

GET /scans/{id}/events is a text/event-stream: it sends the scan at once as an event named scan (the same JSON as GET /scans/{id}, without upload), then again at every change of status, stage or error, and closes after the final state. A ping event every 15 seconds keeps the connection alive; one stream lasts at most 30 minutes, after which you reconnect. Nothing to poll, nothing to miss:

Text
event: scan
id: 1
data: {"object":"scan","id":"scn_8a…","status":"queued",…}

event: scan
id: 2
data: {"object":"scan","id":"scn_8a…","status":"processing","stage":"observing",…}

An account holds at most 10 streams open at once, test and live together: the next one answers 429 RATE_LIMIT_EXCEEDED with Retry-After: 30 and max_streams. Close one, or poll meanwhile; the SDK, the CLI and the MCP server poll for you.

Polling GET /scans/{id} every few seconds works too, and so do the scan.completed and scan.failed webhooks for servers that must not hold a connection.

Screening. Before the analysis, every frame is checked once against the capture profile of the space’s type: is it usable, does it show the space (a room, a vehicle, a boat, an appliance), and which of that type’s key locations does it show. Blurred, dark or covered frames are left out, and the sharpest frame of each part of the video is the one analysed. Footage with no usable frame at all fails with FOOTAGE_UNUSABLE and is refunded. What the frames showed becomes coverage.checklist in the document. To check frames while filming, before a scan, see Guided capture.

Verification. After the analysis, every item, issue and key location is checked again on the frames it cites by a second model that answers closed questions and never writes text (stage: verifying, a few seconds): see Verified evidence.

Then read the space: GET /spaces/spc_3f… returns its Space Document.

Cancel

DELETE /scans/{id} cancels a scan still awaiting_upload (204). Nothing is charged and its uploads are deleted.

With the SDK

TypeScript
import { SpaceContext } from '@spacecontext/sdk';

const sc = new SpaceContext();
const { spaceId, document } = await sc.scan('./flat.mp4', {
  name: 'Seaside flat',
  type: 'apartment',
  onProgress: (p) => console.log(p.type === 'scan' ? p.label : p.type),
});

One call creates the space, uploads, starts, follows the event stream and returns the document (format: 'markdown' or 'skill' adds the export). The steps are also available one by one: spaces.create, scans.fromVideo / scans.fromFrames, scans.events / scans.wait.

Space Context docs · llms.txt · download Edit on GitHub

esc

↑↓ navigate↵ openesc close