NeccessoryNeccessory
Cloud

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 what delivery: '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 URL

Every /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

StatusMeaning
pendingA streaming session that was minted but has not connected yet. Only ever seen on a session id, never on a batch job
queuedAccepted, waiting for a node
runningBeing processed
completedDone. measurementId and measurement are populated
abortedEnded 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")
failedProcessing failed. error.code says why
expiredThe 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:

ConditionError code
Frame rate below 20 fpsinsufficient_frame_rate
Shorter than 20 secondstoo_short
No face in more than 30 % of framesface_not_detected
Median face width under 100 pxface_too_small
Codec or container not supportedunsupported_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:

OffsetSizeField
08magic "NCFRAMES"
81protocol version = 1
93reserved
124packet count, u32 LE
168duration in microseconds, u64 LE
248reserved
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 finishesRegardless of outcome
Hard TTL1 hour from presign, swept independently of job status
Bucket backups and versioningOff
Frames or video in the measurement raw block, in logs, or in metricsNever

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.