Batch Processing
Upload recorded frames or a video file and get a measurement back — presign, PUT, job, result.
Batch mode processes a file instead of a live stream. There are two kinds:
frames— the same packets as the streaming protocol, buffered by the client and uploaded as one file. This is whatdelivery: 'auto'falls back to when the socket fails.video— a recorded mp4 or mov. Your backend sends a file; no browser is involved anywhere. This is the only mode where we decode video and detect the face ourselves.
There are no live events in batch mode. You get the final result only.
The call sequence
POST /sdk/v1/cloud/uploads/presign NCS1 → { uploadUrl, objectKey, expiresAt }
PUT <uploadUrl> ← the file itself
POST /sdk/v1/cloud/jobs NCS1 → { jobId, status: "queued" }
GET /sdk/v1/cloud/jobs/:jobId NCS1 → { status, measurement?, error? }
or a webhook to your URLEvery /sdk/v1 call is signed with NCS1. The PUT
is not — the presigned URL is the credential.
1. Get an upload URL
POST /sdk/v1/cloud/uploads/presign
// request
{ "kind": "frames" | "video", "contentType": "application/octet-stream" | "video/mp4" | "video/quicktime" | "video/webm" }
// 200
{ "ok": true, "data": {
"uploadUrl": "https://…",
"objectKey": "up_…",
"expiresAt": 1785000000000,
"contentType": "application/octet-stream", // what the PUT must declare
"maxBytes": 67108864 // the ceiling for this kind
}}The URL is valid for 15 minutes. contentType echoes what you asked for, or
the default for that kind if you left it out: application/octet-stream for
frames, video/mp4 for video. A video upload may also declare
video/quicktime or video/webm (what browsers' MediaRecorder produces);
anything else is rejected with 400 invalid_content_type.
maxBytes is the size ceiling — 64 MB for frames, 256 MB for video.
Treat `uploadUrl` as a credential
Anyone holding it can write to that object for as long as it is valid. Do not log it, do not put it in an error message, and do not pass it to third-party tooling.
2. Upload the file
A plain PUT of the bytes to uploadUrl, with the contentType you declared.
3. Create the job
POST /sdk/v1/cloud/jobs
{
"objectKey": "up_…", // required
"kind": "frames" | "video", // required
"sha256": "<64 hex>", // required — SHA256 of the uploaded file
"idempotencyKey": "<16..200 chars>", // required
"platform": "web" | "ios" | "android" | "server",
"features": ["hrv", "bp", "spo2"], // optional; defaults to all of them
"consent": true, // required
"webhookUrl": "https://…", // optional
"clientRef": "user-42", // optional
"supersedes": "cj_…" // optional: job this one replaces
}// 201
{ "ok": true, "data": { "jobId": "cj_…", "status": "queued", "createdAt": 1785000000000 } }features narrows what the job computes. It is recorded on the job and
travels with it into processing, so asking for ["hrv"] alone is a way to skip
work you are not going to read. Unknown names are dropped rather than rejected,
and omitting the field entirely means "everything this tier can do": hrv, bp,
spo2, emotion, gaze. Core metrics — heart rate, breathing rate, stress —
are always computed and cannot be switched off. The streaming equivalent is the
same list baked into the session token, echoed back to you as acceptedFeatures
in the ready event.
Idempotency. Uniqueness is (key, idempotencyKey). Repeating the call
returns the same job with its current status instead of creating a second
one. The sha256 lets that survive a re-upload: same idempotency key and same
hash means "this is a retry"; same key but a different hash is a
409 idempotency_conflict. The key itself must be 16–200 characters; outside
that range the call is a 400 validation_error.
supersedes names a job this one replaces — an interrupted streaming session
whose frames you are re-submitting yourself. The named job is marked aborted
and its seconds are not counted, so the same capture is never metered twice. The
SDK's own delivery: 'auto' fallback does this for you and you never set the
field by hand.
Errors: 400 invalid_kind · 400 consent_required · 400 validation_error ·
404 object_not_found · 409 idempotency_conflict · 413 object_too_large ·
422 sha256_mismatch.
4. Collect the result
GET /sdk/v1/cloud/jobs/:jobId
{ "ok": true, "data": {
"jobId": "cj_…",
"mode": "batch_frames", // batch_frames | batch_video | stream
"status": "queued",
"createdAt": 1785000000000, "startedAt": …, "finishedAt": …,
"framesProcessed": 1780,
"secondsProcessed": 59.3,
"measurementId": "meas_…", // when completed
"measurement": { /* MeasurementResult */ },
"error": { "code": "…", "message": "…" } // when failed
}}Every status you can see
| Status | Meaning |
|---|---|
pending | A streaming session that was minted but has not connected yet. Only ever seen on a session id, never on a batch job |
queued | Accepted, waiting for a node |
running | Being processed |
completed | Done. measurementId and measurement are populated |
aborted | Ended without a result but not through a failure: the client disconnected, aborted, or went idle, or the session was superseded by a delivery: 'auto' fallback (error.code: "superseded") |
failed | Processing failed. error.code says why |
expired | The uploaded object was swept by the 1-hour TTL before the job ran |
aborted and pending matter to a poller: neither is terminal in the way
completed is, and neither carries a measurement. Treat completed, failed,
aborted and expired as "stop polling"; pending and queued and running
as "keep waiting".
Or supply webhookUrl and we POST the same body to it when the job settles.
The webhook is signed with NCS1 using your own key (x-key-id is your key
id), so you verify it with the same function you use to sign your requests. Three
attempts with exponential backoff.
Organization webhooks
The same settle events — completed, failed, aborted, expired — also go
to your organization's webhook endpoints, configured in the console with their
own whsec_ secret and a delivery journal. The per-job webhookUrl is
unchanged and the two coexist. See Webhooks.
A completed batch measurement carries origin: "cloud" and attested: true,
exactly like a streamed one — see Attestation.
Requirements for batch_video
This is where quality is most at risk, because we receive whatever the recorder produced. These are hard rejections:
| Condition | Error code |
|---|---|
| Frame rate below 20 fps | insufficient_frame_rate |
| Shorter than 20 seconds | too_short |
| No face in more than 30 % of frames | face_not_detected |
| Median face width under 100 px | face_too_small |
| Codec or container not supported | unsupported_media |
Recompressed video does not work
Encode intra-only, or at a bitrate of at least 10 Mbit/s. Video captured through a video call, a screen recording, or anything transcoded to MPEG-4 is not supported: inter-frame prediction removes the pulse signal itself, and no amount of processing recovers it.
There is no separate compression-artifact detector, and none is needed. A
degraded recording yields a low signal-quality index, which yields
quality.level: "poor", and at poor every metric is null. The job still
completes — with an honest "no measurement" rather than an invented number.
The file format for frames
The frame packets concatenated, behind a 32-byte file header:
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | magic "NCFRAMES" |
| 8 | 1 | protocol version = 1 |
| 9 | 3 | reserved |
| 12 | 4 | packet count, u32 LE |
| 16 | 8 | duration in microseconds, u64 LE |
| 24 | 8 | reserved |
| 32 | … | packets, each with its own header |
One format for both the stream and the batch means one parser on both sides.
How long the uploaded file lives
| Rule | |
|---|---|
| Deleted as soon as the job finishes | Regardless of outcome |
| Hard TTL | 1 hour from presign, swept independently of job status |
| Bucket backups and versioning | Off |
Frames or video in the measurement raw block, in logs, or in metrics | Never |
Batch is the one place where a file touches disk at all — streaming never does. That is a real weakening of the privacy position and it is compensated by the rules above, not by a promise. See Cloud privacy.
Next: Attestation.