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

  1. A master admin creates the device at /system-management/bottle-counters with its OS serial number. Creation generates a visible 16-letter license key and returns the raw counter API key once.
  2. Enable enableCounterEntryRecording when the device should upload a video for each entry.
  3. 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

  1. POST the count with videoContentType. Save the returned counterEntry.id.
  2. When videoUpload is non-null, PUT the raw file to its URL using the returned method and content type.
  3. Check that the storage upload succeeded, then PATCH the entry’s video-upload endpoint. Check that response too.
  4. 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

POST/api/counters/validate-license

Validate 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
FieldTypeDetails
osSerialNumberstring · requiredTrimmed, 1–255 characters; must match the registered device.
licenseKeystring · requiredExactly 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"
  }
}
FieldTypeDetails
validbooleantrue on success. Invalid/inactive credentials return 401, not valid: false.
counter.idUUIDPhysical counter identifier.
counter.namestringManagement display name.
counter.osSerialNumberstringRegistered serial number.

Invalid credentials return { "error": "Invalid counter license." }.

See response object fields and error responses.

Create a count entry

POST/api/counters/entries

Record 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
FieldTypeDetails
countinteger · requiredNonnegative JSON number; numeric strings are not accepted.
videoContentTypestring · optionalTrimmed, 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
}
FieldTypeDetails
counterEntryCounterEntryPersisted count entry.
videoUploadVideoUpload | nullTemporary storage target when recording is enabled, a MIME type is supplied, and signing succeeds.
videoUploadErrorstring | nullNon-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

PATCH/api/counters/entries/{counterEntryId}/video-upload

After 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

FieldTypeDetails
counterEntryIdUUID · requiredExisting 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
}
FieldTypeDetails
counterEntryCounterEntryUpdated count entry.
videoUploadnullNo upload URL is returned by this action.

See response object fields and error responses.

Regenerate an upload URL

POST/api/counters/entries/{counterEntryId}/video-upload

Get 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/json

Path parameters

FieldTypeDetails
counterEntryIdUUID · requiredExisting entry id belonging to the authenticated counter.
FieldTypeDetails
videoContentTypestring · requiredTrimmed, 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"
  }
}
FieldTypeDetails
videoUploadVideoUploadFresh presigned URL, HTTP method, and required content type. No counterEntry field is returned.

See response object fields and error responses.

Clear an abandoned upload

DELETE/api/counters/entries/{counterEntryId}/video-upload

Delete 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

FieldTypeDetails
counterEntryIdUUID · requiredExisting 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
}
FieldTypeDetails
counterEntryCounterEntryUpdated count entry.
videoUploadnullNo upload URL is returned by this action.

See response object fields and error responses.

List entries for management review

GET/api/counters/{counterId}/entries

Management-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

FieldTypeDetails
counterIdUUID · requiredPhysical counter id.

Query parameters

FieldTypeDetails
fromDateYYYY-MM-DD · optionalValid calendar date. Inclusive UTC midnight start.
toDateYYYY-MM-DD · optionalIncludes 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"
  }
}
FieldTypeDetails
counterEntriesCounterEntry[]Matching entries, including entries without recordings; [] when none match.
appliedDateRange.fromDate / toDatestring | nullApplied UTC dates; omitted boundaries are returned as null.

See response object fields and error responses.

Get a recording review URL

GET/api/counters/{counterId}/entries/{counterEntryId}/video

Get 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

FieldTypeDetails
counterIdUUID · requiredPhysical counter id.
counterEntryIdUUID · requiredExisting 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"
}
FieldTypeDetails
urlstringTemporary 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.

FieldTypeDetails
id / counterIdUUIDEntry id and owning physical counter id.
createdAtISO timestampCreation instant.
countintegerRecorded nonnegative count.
videoFileLocationstring | nullOpaque storage location, not a public URL. Use returned signed URLs.
videoUploadStatusPending | Uploaded | Failed | nullPending: 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.

FieldTypeDetails
urlstringPresigned destination URL, including all query parameters.
methodPUTHTTP method for sending the raw file.
contentTypestringRequired 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.

FieldTypeDetails
400Bad requestInvalid fields, invalid UUID/date filters, missing counter/entry, or an unavailable upload target.
401UnauthorizedInvalid/inactive counter credentials, or missing/inactive management session for review routes.
500Server errorUnexpected 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.