Upstate Recycling Technologies
Public developer docsBottle counters

Bottle counter integration

Use this guide to post count entries from a physical bottle counter and upload count-entry video recordings through the presigned upload URL returned by the API.

License key startup check
Direct recording upload
Private review links

Setup

Provision the counter

Create 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.

Enable recording

Turn on `enableCounterEntryRecording` when the device should upload a video file for each entry.

Store the key

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

EndpointPOST /api/counters/validate-license
AuthThe 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

EndpointPOST /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}`,
      },
    },
  );
}
The storage request is not JSON and does not use the app session cookie or the counter API key. If this `PUT` fails, retry the same presigned URL while it remains valid instead of creating a duplicate count entry. The `PATCH` request does not upload the file or verify object storage; it records the device's confirmation that the direct `PUT` succeeded. If the recording upload is abandoned, clear the upload target.

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}`,
    },
  },
);
This route does not create another count entry. It only signs a new `PUT` URL for an existing entry that belongs to the authenticated counter and already has a recording upload target. After uploading through the regenerated URL, call `PATCH` to mark the recording `Uploaded`.

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