# BarBooster POS poll API

Canonical HTML: https://barbooster.io/docs/pos-api  
Canonical Markdown (for POS firmware and AI): https://barbooster.io/docs/pos-api.md

Integration spec for an on-premise POS / receipt printer.

Paid guest boosts on BarBooster become print jobs. The till polls over HTTPS about once a minute, prints **one receipt per staff member**, then acknowledges. After the drink is handed over, the till can mark the boost **delivered**. The venue mobile app can still confirm delivery the same way — use either path, not both.

If the guest ticked **Receive a short video from the girls**, that add-on is included on the same staff ticket. Print the extra line **and a QR** from `video_addon.upload_url`. Staff scan it, upload the clip on BarBooster, and we email the guest a private watch link. The venue app can upload the same way.

Base URL (production): `https://barbooster.io`

Poll about **once per minute**. An empty `sales` array is normal. Do **not** put the secret in the query string (it ends up in access logs). Use HTTPS only.

---

## Credentials

Created on the venue dashboard: **Settings → POS printer**.

| Config on the POS | Value | Example |
|-------------------|--------|---------|
| Bar / POS identifier | `key_id` | `pos_7k2m9abc12def345` |
| POS secret | shown **once** at generate/rotate | random URL-safe string |

Store both on the till. Rotating credentials in BarBooster invalidates the old secret immediately. One active key per venue — use it on **one printer** so two tills do not double-print.

### Auth (required on every request)

Prefer HTTP Basic (widely supported):

```
Authorization: Basic base64(key_id + ":" + secret)
```

Or custom headers:

```
X-Pos-Id: pos_7k2m9abc12def345
X-Pos-Secret: <secret>
```

CORS allows `GET`, `POST`, `OPTIONS` and headers `Content-Type`, `Accept`, `Authorization`, `X-Pos-Id`, `X-Pos-Secret`. JSON responses use `Content-Type: application/json; charset=utf-8` and `Cache-Control: no-store`.

---

## Recommended POS loop

1. `GET /api/pos/sales.php`
2. If `sales` is empty → sleep 60s → repeat
3. For each sale, print a receipt (staff name, every `items[]` line, amount, guest, time). If `video_addon.requested` is true, print the video add-on line **and a QR of `video_addon.upload_url`**.
4. If print **succeeded**, `POST /api/pos/ack.php` with those `id`s
5. If print **failed**, do **not** ack — the next poll will retry (duplicate print is better than a lost ticket)
6. When staff hand the drink over, `POST /api/pos/delivered.php` with those `id`s (optional — the venue app can do this instead)
7. Sleep 60s → repeat

Ack is **not** sent on GET. Do not ack a sale until the printer confirms the ticket. Delivered is separate from ack: print first, mark delivered when the guest has the drink.

A “boost everyone” checkout is multiple sales (one per staff). Print a separate receipt for each. A video add-on is attached to the staff member it was bought for, not duplicated onto every ticket.

---

## GET `/api/pos/sales.php`

Unprinted paid boosts for this bar from the **last 24 hours**, oldest first, max **50**.

- Method: `GET` only (`405` otherwise)
- Query string: none required
- Auth: required

### Success `200`

```json
{
  "ok": true,
  "sales": [
    {
      "id": 123,
      "order_number": "A1B2C3D4E5F6",
      "kind": "boost",
      "paid_at": "2026-08-25T07:12:00+07:00",
      "currency": "THB",
      "amount": 500.0,
      "guest_name": "James",
      "message": "Great night · Video add-on requested",
      "staff": {
        "id": 9,
        "name": "Anna",
        "job_title": "Bartender"
      },
      "items": [
        {
          "name": "Boost",
          "quantity": 1,
          "unit_price": 500.0
        },
        {
          "name": "Receive a short video from the girls",
          "quantity": 1,
          "unit_price": 500.0
        }
      ],
      "video_addon": {
        "requested": true,
        "label": "Receive a short video from the girls",
        "amount": 500.0,
        "order_id": 124,
        "order_number": "B2C3D4E5F6A1",
        "print_hint": "Scan QR to upload the thank-you video. Guest is emailed a watch link.",
        "upload_url": "https://barbooster.io/upload-thank-you.php?token=…",
        "qr_image_url": "https://api.qrserver.com/v1/create-qr-code/?data=…",
        "qr_caption": "Scan to upload video"
      },
      "related_ids": [124]
    }
  ]
}
```

Idle bar:

```json
{ "ok": true, "sales": [] }
```

### Sale fields

| Field | Type | Notes |
|--------|------|--------|
| `id` | integer | BarBooster order id. Use this for ack and delivered. |
| `order_number` | string | 12-char hex, e.g. `A1B2C3D4E5F6`. Alternate ack/delivered key. |
| `kind` | string | `boost` (drink/tip) or `video_addon` (standalone video ticket if it could not be folded onto a boost). |
| `paid_at` | string \| null | ISO-8601 in the venue timezone, e.g. `2026-08-25T07:12:00+07:00` |
| `currency` | string | ISO 4217, usually `THB` |
| `amount` | number | Boost subtotal for this staff member (not platform fee, not card surcharge). Does **not** include `video_addon.amount`. |
| `guest_name` | string \| null | Buyer first name / display name. Email is never sent. |
| `message` | string \| null | Optional guest note |
| `staff.id` | integer | Staff member id |
| `staff.name` | string | Name to print on the ticket |
| `staff.job_title` | string \| null | e.g. `Bartender` |
| `items[].name` | string | Product name, e.g. `Boost`, `10 Boosts`, or the video add-on label |
| `items[].quantity` | integer | Usually `1` |
| `items[].unit_price` | number | Unit price in `currency` |
| `video_addon.requested` | boolean | Guest ticked the video add-on for this staff member |
| `video_addon.label` | string | Present when `requested` is true |
| `video_addon.amount` | number | Video add-on price. Add to `amount` for a ticket total. |
| `video_addon.upload_url` | string | Staff upload page. Encode this as a QR on the ticket (preferred). |
| `video_addon.qr_image_url` | string | PNG of that QR if the printer draws bitmaps instead of native QR. |
| `video_addon.qr_caption` | string | Short line under the QR, e.g. `Scan to upload video` |
| `video_addon.print_hint` | string | Till copy: scan QR, guest is emailed a watch link |
| `related_ids` | integer[] | Extra order ids folded onto this ticket. Ack of `id` also acks these. |

Receipt suggestion: staff name, every item name × qty, `amount` + `currency`, video add-on line when `video_addon.requested`, **QR from `upload_url`**, guest name, `paid_at`, `order_number`. Ticket total ≈ `amount` + (`video_addon.amount` if requested).

Staff scan the QR on a phone, record a short video on barbooster.io, then we email the guest a private watch link. The venue app can still upload the same way.

---

## GET `/api/pos/staff.php`

Venue staff roster for POS mapping. New people added on the BarBooster website appear here without waiting for a boost.

- Method: `GET` only (`405` otherwise)
- Query string: none required
- Auth: required

### Success `200`

```json
{
  "ok": true,
  "staff": [
    {
      "id": 9,
      "name": "Anna",
      "job_title": "Bartender",
      "active": true,
      "approved": true,
      "has_photo": false
    }
  ]
}
```

An empty venue:

```json
{ "ok": true, "staff": [] }
```

| Field | Type | Notes |
|-------|------|--------|
| `id` | integer | BarBooster staff id. Use this when mapping to a POS staff member. |
| `name` | string | Display name |
| `job_title` | string \| null | e.g. `Bartender` |
| `active` | boolean | `false` if the profile is switched off on BarBooster |
| `approved` | boolean | `false` if the profile is still waiting for venue approval |
| `has_photo` | boolean | `true` when the profile already has an uploaded photo |

---

## GET `/api/pos/staff-photo.php`

Return one BarBooster profile photo so the till can store it when the linked POS staff member has no photo.

- Method: `GET`
- Auth: required
- Query: `staff_id` (integer, required)

### Success `200`

```json
{
  "ok": true,
  "staff_id": 9,
  "mime": "image/jpeg",
  "image_base64": "..."
}
```

`image_base64` is an empty string when that profile has no photo.

---

## POST `/api/pos/staff-photo.php`

Copy a linked POS staff profile picture onto that BarBooster profile. The till sends this from Admin → Settings → BarBooster → Import profile pictures.

- Method: `POST` (`405` for other methods besides GET)
- Auth: required
- Content-Type: `application/json`

### Body

| Field | Type | Required | Notes |
|-------|------|----------|--------|
| `staff_id` | integer | yes | BarBooster staff id from `GET /api/pos/staff.php` |
| `image_base64` | string | yes | JPG, PNG, GIF, or WebP. Max 8MB decoded. |
| `replace` | boolean | no | When `false` or omitted, a profile that already has a photo is left unchanged. |

### Success `200`

```json
{
  "ok": true,
  "skipped": false,
  "staff_id": 9,
  "styled": true,
  "message": "Photo saved. Background styling is running."
}
```

When the profile already has a photo and `replace` is not set:

```json
{
  "ok": true,
  "skipped": true,
  "staff_id": 9,
  "message": "Profile already has a photo."
}
```

---

## POST `/api/pos/ack.php`

Call **after** a successful print. Marks those orders so they are not returned again. Does **not** mark the boost delivered in BarBooster.

Acking a boost that includes a video add-on also acks the folded video order (`related_ids`) so it is not printed twice.

- Method: `POST` only (`405` otherwise)
- Body: JSON (`Content-Type: application/json`) or form fields
- Auth: required
- Idempotent: ack-ing the same id twice still returns `ok: true`

### Request

```json
{ "order_ids": [123, 124] }
```

Or:

```json
{ "order_numbers": ["A1B2C3D4E5F6"] }
```

You may send both. A single id/number is accepted (not only arrays).

### Success `200`

```json
{
  "ok": true,
  "acked_ids": [123, 124]
}
```

`acked_ids` are the order ids that belong to this venue. Unknown ids are ignored (not an error). Empty `acked_ids` means none of the ids matched this bar.

### Error `422`

```json
{
  "ok": false,
  "error": "invalid_request",
  "message": "Provide order_ids or order_numbers."
}
```

---

## POST `/api/pos/delivered.php`

Call when the drink (or item) has been handed to the guest. Same effect as **Confirm delivery** in the venue mobile app or dashboard. Either the till or the app may do this; the second call is idempotent.

- Method: `POST` only (`405` otherwise)
- Body: same as ack (`order_ids` and/or `order_numbers`)
- Auth: required
- Also sets print-ack so the sale leaves the poll queue

Boosts (`kind: "boost"`) can be marked delivered from the POS even if a video add-on was requested. The thank-you video is still recorded in the venue app.

A dedicated video add-on order (`kind: "video_addon"`, or `video_addon.order_id`) cannot be marked delivered until staff have uploaded the thank-you video in the app. The POS then receives `thank_you_required` for that id; retry after the upload, or let the app confirm it.

### Request

```json
{ "order_ids": [123] }
```

### Success `200`

```json
{
  "ok": true,
  "delivered_ids": [123],
  "failed": []
}
```

`delivered_ids` includes orders that were already delivered (app got there first). Unknown ids are ignored.

### Partial failure `200`

```json
{
  "ok": true,
  "delivered_ids": [123],
  "failed": [
    {
      "id": 124,
      "error": "thank_you_required",
      "message": "This order includes a video return. Staff must upload the thank-you video before delivery can be confirmed."
    }
  ]
}
```

Treat `thank_you_required` as “wait for the app upload”, not as a network retry loop.

### Error `422`

```json
{
  "ok": false,
  "error": "invalid_request",
  "message": "Provide order_ids or order_numbers."
}
```

---

## Errors (all endpoints)

Every error body is:

```json
{
  "ok": false,
  "error": "unauthorized",
  "message": "Invalid POS identifier or secret."
}
```

| HTTP | `error` | When |
|------|---------|------|
| 401 | `unauthorized` | Missing or wrong identifier/secret |
| 403 | `venue_unavailable` | Venue inactive / not found |
| 405 | `method_not_allowed` | Wrong HTTP method |
| 422 | `invalid_request` | Ack body missing ids/numbers |
| 503 | `db_unavailable` | Temporary; retry next minute |

`OPTIONS` returns `204` (CORS preflight).

Treat network failures and `5xx` as “try again next poll”. Do not ack unless you received `200` after a successful print.

---

## curl

Replace host, identifier, and secret.

```bash
# Poll (Basic auth)
curl -sS -u 'pos_IDENTIFIER:SECRET' \
  'https://barbooster.io/api/pos/sales.php'

# Poll (headers)
curl -sS \
  -H 'X-Pos-Id: pos_IDENTIFIER' \
  -H 'X-Pos-Secret: SECRET' \
  'https://barbooster.io/api/pos/sales.php'

# Staff roster (for POS mapping)
curl -sS -u 'pos_IDENTIFIER:SECRET' \
  'https://barbooster.io/api/pos/staff.php'

# Ack after print
curl -sS -u 'pos_IDENTIFIER:SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"order_ids":[123]}' \
  'https://barbooster.io/api/pos/ack.php'

# Mark delivered (drink handed over) — same as the venue app
curl -sS -u 'pos_IDENTIFIER:SECRET' \
  -H 'Content-Type: application/json' \
  -d '{"order_ids":[123]}' \
  'https://barbooster.io/api/pos/delivered.php'
```

---

## POS implementation notes (for Cursor)

- Persist `key_id` + secret per venue/till in POS config (not in source control).
- Sync `GET /api/pos/staff.php` when opening Admin → Settings → BarBooster so commission staff can be mapped before the first boost.
- Timer: every 60 seconds; skip overlapping polls if a previous request is still in flight.
- Print **synchronously** per sale; collect successfully printed `id`s; one ack POST for the batch is fine.
- When `video_addon.requested` is true, print that add-on on the same ticket and print a QR of `video_addon.upload_url` (native ESC/POS QR, or `qr_image_url`). Caption: `Scan to upload video`. After upload, BarBooster emails the guest a watch link — the till does not send email.
- Optional: after hand-over, POST delivered with the same `id`s. The venue app may already have done this — `delivered_ids` still returns those ids.
- Do not persist BarBooster sales as POS table sales unless you want them in Z-reports — this API is a print feed, not a payment settlement feed. Money already settled on Stripe.
- Local staging host may differ (`https://ladydrink.local` or your tunnel); production is `https://barbooster.io`.
