Integration reference
Bottle counter integration
Validate a device, record counts, and upload recordings directly to storage. Follow the lifecycle without creating duplicate count entries.
Provision the counter
- A master admin creates the device at
/system-management/bottle-counterswith its OS serial number. Creation generates a visible 16-letter license key and returns the raw counter API key once. - Enable
enableCounterEntryRecordingwhen the device should upload a video for each entry. - Keep both credentials on the device. Validate the serial/license at startup; use the counter API key for counts and upload lifecycle requests.
Authorization: Bearer <counter-api-key>
# Alternative:
x-counter-api-key: <counter-api-key>The Bearer header takes precedence. The counter must be active. These credentials are separate from the system API keys used for customer accounts. Management review requires a master-admin session.
Use https://bottlespeed.com for API calls. IDs, serial numbers, keys, and storage URLs in the examples are fictional.
Upload a recording
- POST the count with
videoContentType. Save the returnedcounterEntry.id. - When
videoUploadis non-null, PUT the raw file to its URL using the returned method and content type. - Check that the storage upload succeeded, then PATCH the entry’s video-upload endpoint. Check that response too.
- If the URL expires, regenerate it for the same entry. If abandoning the recording, DELETE its target. Do not repost the count to retry a recording.
Storage request · PUT the returned URL
PUT <videoUpload.url>
Content-Type: <videoUpload.contentType>
<raw recording bytes>The URL itself authorizes this storage request. Do not send an API key, app cookies, JSON, base64, or a multipart wrapper. Use the returned content type exactly. This is a storage response, not a BottleSpeed JSON object; check its success status before confirming.
const uploaded = await fetch(entryBody.videoUpload.url, {
method: entryBody.videoUpload.method,
headers: { 'Content-Type': entryBody.videoUpload.contentType },
body: recordingFile,
credentials: 'omit',
});
if (!uploaded.ok) throw new Error('Recording upload failed');
const completed = await fetch(
'https://bottlespeed.com/api/counters/entries/' + entryBody.counterEntry.id + '/video-upload',
{ method: 'PATCH', headers: { Authorization: 'Bearer ' + counterApiKey } },
);
if (!completed.ok) throw new Error('Upload confirmation failed');An entry can be saved with no upload URL when recording is disabled, the MIME type is omitted, or URL signing fails. Check videoUploadError. For count-only entries, send {"count":120}; the response has null recording fields and no upload target.
Validate the counter license
/api/counters/validate-licenseValidate the device at startup using its registered OS serial number and license key. A successful license check does not replace API-key authentication on subsequent requests.
Authentication: OS serial number and license key in the JSON body; no API-key header.
Request
POST /api/counters/validate-license
Content-Type: application/json| Field | Type | Details |
|---|---|---|
| osSerialNumber | string · required | Trimmed, 1–255 characters; must match the registered device. |
| licenseKey | string · required | Exactly 16 uppercase letters A–Z after trimming. Unknown fields are rejected. |
{
"osSerialNumber": "COUNTER-OS-001",
"licenseKey": "ABCDEFGHIJKLMNOP"
}Response · 200 OK
{
"valid": true,
"counter": {
"id": "11111111-1111-4111-8111-111111111111",
"name": "Front counter",
"osSerialNumber": "COUNTER-OS-001"
}
}| Field | Type | Details |
|---|---|---|
| valid | boolean | true on success. Invalid/inactive credentials return 401, not valid: false. |
| counter.id | UUID | Physical counter identifier. |
| counter.name | string | Management display name. |
| counter.osSerialNumber | string | Registered serial number. |
Invalid credentials return { "error": "Invalid counter license." }.
See response object fields and error responses.
Create a count entry
/api/counters/entriesRecord a count and optionally prepare a raw-video upload target when entry recording is enabled for this counter.
Authentication: Active counter API key via Authorization: Bearer or x-counter-api-key. A Bearer header takes precedence.
Request
POST /api/counters/entries
Authorization: Bearer <counter-api-key>
Content-Type: application/json| Field | Type | Details |
|---|---|---|
| count | integer · required | Nonnegative JSON number; numeric strings are not accepted. |
| videoContentType | string · optional | Trimmed, 1–255 characters, beginning with video/ (case-insensitive). Omit for a count-only entry. New ZIP uploads are not accepted. |
{
"count": 120,
"videoContentType": "video/mp4"
}Response · 200 OK
{
"counterEntry": {
"id": "22222222-2222-4222-8222-222222222222",
"createdAt": "2026-09-15T12:00:00.000Z",
"counterId": "11111111-1111-4111-8111-111111111111",
"count": 120,
"videoFileLocation": "counter-entries/11111111-1111-4111-8111-111111111111/22222222-2222-4222-8222-222222222222/video.mp4",
"videoUploadStatus": "Pending"
},
"videoUpload": {
"url": "https://storage.example.com/presigned-upload-target",
"method": "PUT",
"contentType": "video/mp4"
},
"videoUploadError": null
}| Field | Type | Details |
|---|---|---|
| counterEntry | CounterEntry | Persisted count entry. |
| videoUpload | VideoUpload | null | Temporary storage target when recording is enabled, a MIME type is supplied, and signing succeeds. |
| videoUploadError | string | null | Non-fatal signing error; the count entry can still be saved with HTTP 200. |
For count-only or recording-disabled entries, counterEntry.videoFileLocation, counterEntry.videoUploadStatus, and videoUpload are null. A signing failure also yields no target and includes videoUploadError. Do not repost the count just because its upload failed; preserve the returned entry id.
See response object fields and error responses.
Confirm an uploaded recording
/api/counters/entries/{counterEntryId}/video-uploadAfter the direct storage PUT succeeds, mark the existing entry as Uploaded. This records the device’s confirmation; it does not upload bytes.
Authentication: Active counter API key via Authorization: Bearer or x-counter-api-key. A Bearer header takes precedence.
Request
PATCH /api/counters/entries/{counterEntryId}/video-upload
Authorization: Bearer <counter-api-key>Path parameters
| Field | Type | Details |
|---|---|---|
| counterEntryId | UUID · required | Existing entry id belonging to the authenticated counter. |
Request body: none.
The entry must still have an upload target. Missing entries or cleared/count-only targets return 400.
Response · 200 OK
{
"counterEntry": {
"id": "22222222-2222-4222-8222-222222222222",
"createdAt": "2026-09-15T12:00:00.000Z",
"counterId": "11111111-1111-4111-8111-111111111111",
"count": 120,
"videoFileLocation": "counter-entries/11111111-1111-4111-8111-111111111111/22222222-2222-4222-8222-222222222222/video.mp4",
"videoUploadStatus": "Uploaded"
},
"videoUpload": null
}| Field | Type | Details |
|---|---|---|
| counterEntry | CounterEntry | Updated count entry. |
| videoUpload | null | No upload URL is returned by this action. |
See response object fields and error responses.
Regenerate an upload URL
/api/counters/entries/{counterEntryId}/video-uploadGet a fresh URL when the original expires before upload. Reuses the existing entry and storage location without creating another count.
Authentication: Active counter API key via Authorization: Bearer or x-counter-api-key. A Bearer header takes precedence.
Request
POST /api/counters/entries/{counterEntryId}/video-upload
Authorization: Bearer <counter-api-key>
Content-Type: application/jsonPath parameters
| Field | Type | Details |
|---|---|---|
| counterEntryId | UUID · required | Existing entry id belonging to the authenticated counter. |
| Field | Type | Details |
|---|---|---|
| videoContentType | string · required | Trimmed, 1–255 characters, starting with video/. Send the recording’s MIME type; unknown fields are rejected. |
{
"videoContentType": "video/mp4"
}Requires an existing videoFileLocation; cannot create a target for a count-only entry or one whose target was cleared. Upload with the new URL, then PATCH to confirm.
Response · 200 OK
{
"videoUpload": {
"url": "https://storage.example.com/presigned-upload-target",
"method": "PUT",
"contentType": "video/mp4"
}
}| Field | Type | Details |
|---|---|---|
| videoUpload | VideoUpload | Fresh presigned URL, HTTP method, and required content type. No counterEntry field is returned. |
See response object fields and error responses.
Clear an abandoned upload
/api/counters/entries/{counterEntryId}/video-uploadDelete the recording object if present, clear the upload target, and mark the recording Failed. The count entry remains.
Authentication: Active counter API key via Authorization: Bearer or x-counter-api-key. A Bearer header takes precedence.
Request
DELETE /api/counters/entries/{counterEntryId}/video-upload
Authorization: Bearer <counter-api-key>Path parameters
| Field | Type | Details |
|---|---|---|
| counterEntryId | UUID · required | Existing entry id belonging to the authenticated counter. |
Request body: none.
Call only when abandoning the recording. Once cleared, PATCH and URL regeneration return 400 because there is no target. Repeated cleanup also returns 400.
Response · 200 OK
{
"counterEntry": {
"id": "22222222-2222-4222-8222-222222222222",
"createdAt": "2026-09-15T12:00:00.000Z",
"counterId": "11111111-1111-4111-8111-111111111111",
"count": 120,
"videoFileLocation": null,
"videoUploadStatus": "Failed"
},
"videoUpload": null
}| Field | Type | Details |
|---|---|---|
| counterEntry | CounterEntry | Updated count entry. |
| videoUpload | null | No upload URL is returned by this action. |
See response object fields and error responses.
List entries for management review
/api/counters/{counterId}/entriesManagement-only history for a physical counter, newest first by timestamp and id. No pagination.
Authentication: Signed-in active master admin, verified through the system_management_auth cookie. Device keys and system API keys do not authorize management review.
Request
GET /api/counters/{counterId}/entries?fromDate=2026-09-15&toDate=2026-09-15
Cookie: system_management_auth=<management-session>Path parameters
| Field | Type | Details |
|---|---|---|
| counterId | UUID · required | Physical counter id. |
Query parameters
| Field | Type | Details |
|---|---|---|
| fromDate | YYYY-MM-DD · optional | Valid calendar date. Inclusive UTC midnight start. |
| toDate | YYYY-MM-DD · optional | Includes the full UTC day; cannot precede fromDate. |
Request body: none.
Response · 200 OK
{
"counterEntries": [
{
"id": "22222222-2222-4222-8222-222222222222",
"createdAt": "2026-09-15T12:00:00.000Z",
"counterId": "11111111-1111-4111-8111-111111111111",
"count": 120,
"videoFileLocation": "counter-entries/11111111-1111-4111-8111-111111111111/22222222-2222-4222-8222-222222222222/video.mp4",
"videoUploadStatus": "Uploaded"
}
],
"appliedDateRange": {
"fromDate": "2026-09-15",
"toDate": "2026-09-15"
}
}| Field | Type | Details |
|---|---|---|
| counterEntries | CounterEntry[] | Matching entries, including entries without recordings; [] when none match. |
| appliedDateRange.fromDate / toDate | string | null | Applied UTC dates; omitted boundaries are returned as null. |
See response object fields and error responses.
Get a recording review URL
/api/counters/{counterId}/entries/{counterEntryId}/videoGet a fresh signed URL for an available recording. The API returns JSON containing the URL, not a redirect or the video bytes.
Authentication: Signed-in active master admin, verified through the system_management_auth cookie. Device keys and system API keys do not authorize management review.
Request
GET /api/counters/{counterId}/entries/{counterEntryId}/video
Cookie: system_management_auth=<management-session>Path parameters
| Field | Type | Details |
|---|---|---|
| counterId | UUID · required | Physical counter id. |
| counterEntryId | UUID · required | Existing entry id belonging to the authenticated counter. |
Request body: none.
Requires a stored file location and a status other than Pending/Failed. Legacy records with a file and null status can be reviewed. Missing recordings and incomplete uploads return 400. Existing legacy ZIP recordings may be downloaded here; new uploads accept raw video only.
Response · 200 OK
{
"url": "https://storage.example.com/presigned-read-target"
}| Field | Type | Details |
|---|---|---|
| url | string | Temporary signed GET URL. Open this returned URL to read the recording. |
See response object fields and error responses.
Response object fields
CounterEntry
One persisted count; lifecycle actions change its recording metadata without changing count.
| Field | Type | Details |
|---|---|---|
| id / counterId | UUID | Entry id and owning physical counter id. |
| createdAt | ISO timestamp | Creation instant. |
| count | integer | Recorded nonnegative count. |
| videoFileLocation | string | null | Opaque storage location, not a public URL. Use returned signed URLs. |
| videoUploadStatus | Pending | Uploaded | Failed | null | Pending: target prepared. Uploaded: device confirmed a successful PUT. Failed: target cleared. null: no target or legacy metadata. |
VideoUpload
Returned URLs are temporary secrets. The example storage host is fictional; always use the actual response URL unchanged.
| Field | Type | Details |
|---|---|---|
| url | string | Presigned destination URL, including all query parameters. |
| method | PUT | HTTP method for sending the raw file. |
| contentType | string | Required Content-Type for the upload. |
Error responses
Check the HTTP status before reading success fields. The examples use fictional data. JSON responses can contain null fields; preserve null rather than treating it as an empty string.
| Field | Type | Details |
|---|---|---|
| 400 | Bad request | Invalid fields, invalid UUID/date filters, missing counter/entry, or an unavailable upload target. |
| 401 | Unauthorized | Invalid/inactive counter credentials, or missing/inactive management session for review routes. |
| 500 | Server error | Unexpected failure, including malformed JSON. An uncertain write may have completed; reconcile before retrying. |
Ordinary error · 400
{
"error": "Counter entry not found for this counter."
}Validation error · 400
{
"error": "ValidationError",
"issues": [
{
"expected": "number",
"code": "invalid_type",
"path": [
"count"
],
"message": "Invalid input: expected number, received undefined"
}
]
}error is a string. Validation responses also contain issues, an array with code, path (field names or array indices), and message; other issue fields depend on the validation rule. Missing records use 400 and authorization failures use 401.