Setup
Provision the counter
A master admin creates the physical counter in system management. Creation generates a visible 16-letter license key and returns the raw integration API key once.
Turn on `enableCounterEntryRecording` when the device should upload a video file for each entry.
Keep both the license key and the counter API key on the device. The license key gates startup; the API key records entries.
Startup endpoint
Validate the license
| Endpoint | POST /api/counters/validate-license |
|---|---|
| Auth | The JSON body must include both `osSerialNumber` and `licenseKey`. |
| Body | `licenseKey` must be exactly 16 uppercase alphabetical characters. The serial number and license key must match the same active counter. |
const licenseResponse = await fetch('https://your-domain.example/api/counters/validate-license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
osSerialNumber: deviceSerialNumber,
licenseKey: counterLicenseKey,
}),
});
if (!licenseResponse.ok) {
throw new Error('Counter license validation failed.');
}Entry endpoint
Post a count entry
| Endpoint | POST /api/counters/entries |
|---|---|
| Auth | `Authorization: Bearer <apiKey>` or `x-counter-api-key: <apiKey>` |
| Body | `count` is required and must be a nonnegative integer. `videoContentType` is optional. Include it only when the counter should receive a presigned upload URL for a raw video file. |
const createEntryResponse = await fetch('https://your-domain.example/api/counters/entries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${counterApiKey}`,
},
body: JSON.stringify({
count: countedUnits,
videoContentType: recordingFile.type,
}),
});
const entryBody = await createEntryResponse.json();`videoContentType` must start with `video/` when present. Common values are `video/mp4`, `video/webm`, and `video/quicktime`.
Recording upload
Upload the recording file
When signing succeeds, the entry response includes `videoUpload`. Upload the raw video file directly to that URL with the returned method and content type. After the direct storage upload succeeds, call `PATCH /api/counters/entries/[counterEntryId]/video-upload` to mark the recording as `Uploaded`.
if (createEntryResponse.ok && entryBody.videoUpload) {
const uploadResponse = await fetch(entryBody.videoUpload.url, {
method: entryBody.videoUpload.method,
headers: {
'Content-Type': entryBody.videoUpload.contentType,
},
body: recordingFile,
credentials: 'omit',
});
if (!uploadResponse.ok) {
throw new Error('Recording upload failed.');
}
await fetch(
`https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${counterApiKey}`,
},
},
);
}Expired upload URL
Regenerate the upload target
If the original `videoUpload.url` expires before the device sends the recording, request a fresh target with `POST /api/counters/entries/[counterEntryId]/video-upload`. Authenticate with the same counter API key and send the same recording MIME type in `videoContentType`.
const regenerateUploadResponse = await fetch(
`https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${counterApiKey}`,
},
body: JSON.stringify({
videoContentType: recordingFile.type,
}),
},
);
const regeneratedUploadBody = await regenerateUploadResponse.json();
const regeneratedFileUploadResponse = await fetch(regeneratedUploadBody.videoUpload.url, {
method: regeneratedUploadBody.videoUpload.method,
headers: {
'Content-Type': regeneratedUploadBody.videoUpload.contentType,
},
body: recordingFile,
credentials: 'omit',
});
if (!regeneratedFileUploadResponse.ok) {
throw new Error('Recording upload failed.');
}
await fetch(
`https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${counterApiKey}`,
},
},
);If the device gives up on uploading the recording, call `DELETE /api/counters/entries/[counterEntryId]/video-upload`. The route keeps the count entry, marks the upload failed, and clears its recording pointer.
await fetch(
`https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${counterApiKey}`,
},
},
);Responses
Handle each response state
`videoUpload` is present
The entry was recorded and the API prepared a presigned upload target. Upload the video with the returned `url`, `method`, and `contentType`. The entry has `videoUploadStatus: Pending` until the device calls `PATCH` after a successful direct upload.
`videoUploadStatus` changes
`Pending` means the app prepared an upload target but has not received the device's success confirmation. `Uploaded` means the device called `PATCH` after the direct storage `PUT` succeeded. `Failed` means the device abandoned the upload and cleared the target.
`videoUpload` is null
The count entry still exists. The request may have omitted `videoContentType`, recording may be disabled, or signing may have failed. Check `videoUploadError` for a non-fatal signing error.
HTTP response is not OK
Do not upload a recording file. Fix the authentication or validation issue and post the count entry again.
Count only
Post count-only entries
To save only the count, omit `videoContentType`. The API records the count and returns `videoUpload: null`, even when entry recording is enabled for the counter.
await fetch('https://your-domain.example/api/counters/entries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-counter-api-key': counterApiKey,
},
body: JSON.stringify({
count: countedUnits,
}),
});Management review
Review uploaded recordings
Master admins review entries in the management UI. The history view lists entries with optional UTC `fromDate` and `toDate` filters through `GET /api/counters/[counterId]/entries`, then requests a fresh presigned read URL through `GET /api/counters/[counterId]/entries/[counterEntryId]/video` when an entry has an uploaded recording. Legacy `.zip` entries, if already present, open through the same read route instead of being embedded as playable video.
Back to main site