# Bottle counter integration

Public guide: /docs/bottle-counter-integration. Navigation: [Get started](/docs/get-started) · [Customer accounts](/docs/customer-accounts-integration) · [Bottle counters](/docs/bottle-counter-integration).

Examples use fictional IDs, contacts, credentials, and storage URLs. Replace path placeholders with real values. JSON under Request is the HTTP body. Timestamps are ISO 8601 strings. See the response object field reference for nested field types.

## Provision the counter

A master admin creates the device with its OS serial number at /system-management/bottle-counters. Creation generates a visible 16-letter license key and reveals the counter API key once. Enable enableCounterEntryRecording to prepare recording uploads. Keep both credentials on the device.

Use https://bottlespeed.com for API calls. Validate the serial/license at startup; send Authorization: Bearer <counter-api-key> or x-counter-api-key: <counter-api-key> for entries and upload lifecycle requests. Bearer takes precedence. Counter keys are different from the system keys described in [Get started](/docs/get-started). Management review requires an active master-admin session.

## Direct storage upload

1. POST a count with videoContentType and save counterEntry.id.
2. If videoUpload is non-null, PUT raw recording bytes to videoUpload.url using its method and contentType.
3. Check storage success, then PATCH the existing entry's video-upload endpoint; check that response too.
4. Regenerate an expired URL for the same entry. DELETE the target only when abandoning the recording. Do not repost a saved count to retry an upload.

```http
PUT <videoUpload.url>
Content-Type: <videoUpload.contentType>

<raw recording bytes>
```

The signed URL authorizes the storage request. Do not attach API keys, app cookies, JSON, base64, or a multipart wrapper. Use credentials: 'omit' in browser fetch. The response is from storage, not a BottleSpeed JSON object; check its success status before confirming.

An entry can be saved without an upload URL when recording is disabled, the MIME type is omitted, or signing fails. Inspect videoUploadError. For count-only entries send {"count":120}; recording fields and videoUpload are null. New uploads accept raw video, not ZIP. Existing legacy ZIP files can still be reviewed.

## Validate the counter 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

```http
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. |


```json
{
  "osSerialNumber": "COUNTER-OS-001",
  "licenseKey": "ABCDEFGHIJKLMNOP"
}
```


### Response · 200 OK


```json
{
  "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." }.

## Create a count entry

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

```http
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. |


```json
{
  "count": 120,
  "videoContentType": "video/mp4"
}
```


### Response · 200 OK


```json
{
  "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.

## Confirm an uploaded recording

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

```http
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


```json
{
  "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. |

## Regenerate an upload URL

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

```http
POST /api/counters/entries/{counterEntryId}/video-upload
Authorization: Bearer <counter-api-key>
Content-Type: application/json
```

#### Path 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. |


```json
{
  "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


```json
{
  "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. |

## Clear an abandoned 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

```http
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


```json
{
  "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. |

## List entries for management review

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

```http
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


```json
{
  "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. |

## Get a recording review URL

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

```http
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


```json
{
  "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. |

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

Success responses above use HTTP 200. Ordinary errors have a string error field. HTTP 400 means invalid fields/IDs, missing records, or failed preconditions. HTTP 401 means invalid credentials, inactive membership/counter, insufficient role, or a mutation attempted with a Read key. Unexpected failures, including malformed JSON, may return 500. Reconcile uncertain writes before retrying. Missing records use 400 and authorization failures use 401.


```json
{
  "error": "Counter entry not found for this counter."
}
```


Validation errors return error: "ValidationError" and an issues array. Each issue includes code, path (field names or array indices), and message, with additional rule-specific fields. For example:


```json
{
  "error": "ValidationError",
  "issues": [
    {
      "expected": "number",
      "code": "invalid_type",
      "path": [
        "count"
      ],
      "message": "Invalid input: expected number, received undefined"
    }
  ]
}
```
