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().
API=https://api.spacecontext.app/v1
AUTH="Authorization: Bearer $SPACECONTEXT_API_KEY"1. Create a space
curl -X POST $API/spaces -H "$AUTH" -H "Content-Type: application/json" \
-d '{"name": "Seaside flat", "space_type": "apartment"}'{ "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):
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_type | Container | Read inside it |
|---|---|---|
video/mp4, video/quicktime, video/x-m4v, video/3gpp | MP4, MOV, M4V, 3GP | H.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/matroska | WebM, 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:
{ "input": { "kind": "frames", "language": "it", "frames": [{ "timestamp_ms": 0 }, { "timestamp_ms": 2000 }] } }language is the language the report is written in, en by default:
| Code | Language |
|---|---|
en | English |
it | Italiano |
es | Español |
fr | Français |
de | Deutsch |
pt | Português |
nl | Nederlands |
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:
{
"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.
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:
curl -X PUT $API/scans/scn_8a…/frames/0 -H "$AUTH" -H "Content-Type: image/jpeg" --data-binary @frame-0.jpgUploads can be repeated. A scan not started within 24 hours is cancelled and its uploads deleted.
4. Start
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.
| Answer | Meaning |
|---|---|
202 | Started |
200 | Already started; nothing charged again |
409 UPLOAD_INCOMPLETE | Some photos or the video are missing |
429 SCANS_IN_PROGRESS | Ten scans of the account are already processing; start again when one finishes |
402 INSUFFICIENT_FUNDS | Add funds (automatic top-up, if enabled, starts now) |
402 SPENDING_CAP_REACHED | The key’s monthly cap is reached |
5. Follow
curl -N $API/scans/scn_8a…/events -H "$AUTH" # live, as Server-Sent Events
curl $API/scans/scn_8a… -H "$AUTH" # or one lookstatus 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:
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
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.