# Storefront · POS

Part of AppEngine API Documentation. Full index: https://appengine.appmint.io/documentation.md
## POST /storefront/pos/tab

**Open a POS tab**

`operationId: StorefrontController_openPosTab`

Opens a new tab and returns it with `status: "new"`. Every field is optional — an empty body is a valid walk-up tab.

When `alias` is omitted the server auto-numbers one from the tabs currently open at that location, giving `Walk-up #1`, `Walk-up #2` and so on. Because the count is taken at open time, aliases are not reserved: two tabs opened simultaneously at one location can receive the same auto-alias. Pass an explicit `alias` where that matters.

A tab is an `sf_order` and lives in the same collection as web orders with the same shape — a unique `number`, `productItems[]`, and the monetary fields — so reporting and integrations see one consistent model regardless of channel.

#### Signature

```http
POST /storefront/pos/tab (body) -> The created tab
```

#### Access

Public — no credentials required.

#### Notes

- Auto-generated aliases are not unique under concurrency. Send an explicit `alias` when you need a guaranteed one.
- The tab opens empty; add lines with `POST /storefront/order/{id}/items`.
- POS gate (whole register) for the signed-in operator: 423 `readiness_block`; a manager resends with `override`, stamped on the tab as `readinessOverride`. Customers and non-staff callers are never gated.
- Workforce Readiness: with no requirement rules this endpoint behaves exactly as before. `warn` never stops anything — it comes back as `readiness.warnings` in the response.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `423` | READINESS_BLOCK | <name> can't use the register: <requirement titles>. | A requirement rule whose `enforcement.pos` effect is block (or override-with-reason) is unmet for this person, and no override is active. | Show `message` and `reasons`. A location manager or HR can resend the same request with `override: { reasonCode, reason, expiresAt }` — it is written to the readiness ledger with the approver and expiry. The person’s own override is refused. |
| `400` | OVERRIDE_INVALID | An override needs reasonCode, reason, expiresAt. | `override` is missing a field, has an unknown reason code, or an expiry in the past. | — |
| `403` | OVERRIDE_FORBIDDEN | Sign in as a location manager or HR to override. | The caller may not approve an override (no caller, the person themselves, or only their own supervisor). | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/pos/tabs`
- `POST /storefront/order/{id}/items`
- `POST /storefront/pos/tab/{id}/settle`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Request body

Tab details. All fields optional.

```json
{}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The created tab |
| `400` | An override needs reasonCode, reason, expiresAt. — `override` is missing a field, has an unknown reason code, or an expiry in the past. |
| `403` | Sign in as a location manager or HR to override. — The caller may not approve an override (no caller, the person themselves, or only their own supervisor). |
| `423` | <name> can't use the register: <requirement titles>. — A requirement rule whose `enforcement.pos` effect is block (or override-with-reason) is unmet for this person, and no override is active. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/pos/readiness

**May the signed-in operator use the POS?**

`operationId: StorefrontController_posOperatorReadiness`

The Workforce Readiness `pos` gate, asked when the POS screen opens. Never throws for a block — it describes it, so the screen can explain and offer the override. Pass `permissionScope` (e.g. `alcohol`) to ask about one gated permission. The same gate is enforced server-side on POST /storefront/pos/tab and POST /storefront/order/{id}/items.

#### Signature

```http
GET /storefront/pos/readiness (businessLocationId?: string, permissionScope?: string) -> `{ allowed, staff, permissionScope, effect, employeeId, employeeName, blocking[], warnings[], reasons[], overrideActive }`; when not allowed, also the standard block fields (`reason: "readiness_block"`, `message`, `canOverride`, `override`). A caller who isn’t staff gets `{ allowed: true, staff: false }`.
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Notes

- Workforce Readiness: with no requirement rules this endpoint behaves exactly as before. `warn` never stops anything — it comes back as `readiness.warnings` in the response.

#### Errors

Plus the standard platform errors: `401`, `403`, `429`, `500`.

#### See also

- `POST /storefront/pos/tab`
- `POST /storefront/order/{id}/items`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `businessLocationId` | query | string | — | The register’s location. |
| `permissionScope` | query | string | — | One POS permission to check, e.g. alcohol. Empty = the whole register. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | `{ allowed, staff, permissionScope, effect, employeeId, employeeName, blocking[], warnings[], reasons[], overrideActive }`; when not allowed, also the standard block fields (`reason: "readiness_block"`, `message`, `canOverride`, `override`). A caller who isn’t staff gets `{ allowed: true, staff: false }`. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "allowed": false,
  "staff": true,
  "permissionScope": "alcohol",
  "reason": "readiness_block",
  "code": "READINESS_BLOCK",
  "gate": "pos",
  "message": "Luis Ramirez can't sell alcohol items: Alcohol server cert.",
  "employeeId": "E012",
  "employeeName": "Luis Ramirez",
  "effect": "block",
  "blocking": [
    {
      "requirementId": "REQ-FOOD01",
      "title": "Food handler card",
      "kind": "certification",
      "status": "expired",
      "expiresAt": "2026-09-20T00:00:00.000Z"
    }
  ],
  "warnings": [],
  "reasons": [
    {
      "requirementId": "REQ-FOOD01",
      "title": "Food handler card",
      "kind": "certification",
      "status": "expired",
      "severity": "block"
    }
  ],
  "canOverride": true,
  "override": {
    "how": "Send the same request again with override: { reasonCode, reason, expiresAt }.",
    "fields": [
      "reasonCode",
      "reason",
      "expiresAt"
    ],
    "reasonCodes": [
      "renewal_booked",
      "awaiting_document",
      "training_scheduled",
      "business_need",
      "not_applicable",
      "system_error",
      "other"
    ],
    "approver": "A location manager or HR. The person’s own supervisor alone cannot approve."
  }
}
```

## GET /storefront/pos/tabs

**List open POS tabs**

`operationId: StorefrontController_listPosTabs`

Returns the tabs an operator might still act on, newest first, with line items task-enriched.

"Open" is defined by exclusion: any tab whose status is **not** one of `completed`, `cancelled`, `returned`, `refunded`, `failed` is returned. That deliberately includes **fully-paid tabs** — a paid tab stays on the floor until the operator closes it out — and it makes the list resilient to unexpected status values rather than hiding them.

#### Signature

```http
GET /storefront/pos/tabs (businessLocationId?: string) -> Open tabs, newest first
```

#### Access

Public — no credentials required.

#### Notes

- Capped at 200 tabs. There is no pagination — a location with more than 200 open tabs is truncated.
- Paid-but-not-closed tabs appear here, not in the closed list. Check `amountPaid` against `amount` to tell them apart.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/pos/tabs/closed`
- `POST /storefront/pos/tab`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `businessLocationId` | query | string | — | Restrict to one location. Omit to list every location in the org. |
| `unpaid` | query | string | yes |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Open tabs, newest first |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/pos/tabs/closed

**List closed POS tabs**

`operationId: StorefrontController_listClosedPosTabs`

The exact complement of the open list: tabs whose status **is** one of `completed`, `cancelled`, `returned`, `refunded`, `failed`, newest first, with line items task-enriched. Intended for audit and reporting views ("show me yesterday's tabs").

#### Signature

```http
GET /storefront/pos/tabs/closed (businessLocationId?: string) -> Closed tabs, newest first
```

#### Access

Public — no credentials required.

#### Notes

- Capped at 200 tabs with no pagination or date filter, so this is not a full historical export. Query `sf_order` through the repository API for reporting over long ranges.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/pos/tabs`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `businessLocationId` | query | string | — | Restrict to one location. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Closed tabs, newest first |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/order/{id}

**Get an order with tasks enriched**

`operationId: StorefrontController_getOrderById`

Returns one order or tab by `sk`, with `productItems[].task` populated for every line that has been fired. This is the read to use when rendering a tab, because it resolves workflow state in the same round trip.

#### Signature

```http
GET /storefront/order/{id} (id: string) -> The order with fired lines task-enriched
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/order/{id}/payments`
- `POST /storefront/order/{id}/fire`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Order `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The order with fired lines task-enriched |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/order/{id}/payments

**List payments and balance for an order**

`operationId: StorefrontController_getOrderPayments`

The authoritative payment view for an order. Matches `sf_transaction.invoiceNumber` case-insensitively against the order `sk`, display number and name, then returns every linked transaction together with computed `totalPaid`, `balance` and `paymentStatus`.

Use this rather than reconstructing payment state on the client — the matching rules and the refund arithmetic live on the server, and client-side heuristics have historically disagreed with the ledger.

#### Signature

```http
GET /storefront/order/{id}/payments (id: string) -> Linked transactions plus computed totals
```

#### Access

Public — no credentials required.

#### Errors

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/settle`
- `POST /storefront/pos/tab/{id}/refund`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Order `sk`, display number, or name. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Linked transactions plus computed totals |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/settle

**Settle a POS tab**

`operationId: StorefrontController_settlePosTab`

Records a tender against the tab, increments `amountPaid`, appends to `payments[]` and recomputes the status to `paid` or `paid-partial`.

An `sf_transaction` is **always** written, including for cash, so accounting and reporting see every tender. When `gateway` is `stripe` the transaction is verified with Stripe before it is recorded; every other gateway is recorded as paid offline on trust.

Partial settlement is supported — call it repeatedly until the balance reaches zero. Each call is a new tender, so this endpoint is **not idempotent**: a retried request records a second payment.

**Overpayment**: only a cash tender may exceed what is due — the payment is recorded as the amount due and `tendered`/change are kept on the payment line. Any other tender for more than is due is refused. For a cash share of a split, send `tendered` (the note handed over) and the change is worked out against `amount + tip`.

**Gift cards** are paid by redeeming them first (`POST /storefront/giftcards/redeem`) and settling with `gateway: "giftcard"` and the GCT reference; a gift-card tender recorded any other way is refused.

When the tab becomes fully paid the sale is posted to the ledger and stock is deducted. Those results come back as `accounting` and `stock`; if either could not complete it is saved as `needs_attention` for `retry-accounting` / `retry-stock` — never a reason to charge again.

#### Signature

```http
POST /storefront/pos/tab/{id}/settle (id: string, body) -> The updated tab and the recorded transaction
```

#### Access

Public — no credentials required.

#### Notes

- Not idempotent — a retry records an additional tender. Confirm with the payments endpoint before retrying a request whose response you lost.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | ALREADY_PAID | Order is already paid in full | `amountPaid` already covers the order total. | Read `GET /storefront/order/{id}/payments` to confirm the balance before settling. |
| `409` | REFUNDED_SALE | This sale has a recorded cash refund. Use a new tab for another sale; retry refund records separately. | The tab already has a POS refund recorded. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/order/{id}/payments`
- `POST /storefront/pos/tab/{id}/refund`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Open POS tab `sk`. |

### Request body

The tender to record.

```json
{
  "amount": 25.92,
  "method": "cash"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated tab and the recorded transaction |
| `400` | Order is already paid in full — `amountPaid` already covers the order total. |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `409` | This sale has a recorded cash refund. Use a new tab for another sale; retry refund records separately. — The tab already has a POS refund recorded. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/pos/tab/{id}/breakdown

**Get the money breakdown of a POS tab**

`operationId: StorefrontController_posTabBreakdown`

Read-only, server-priced figures for the settle screen: the recorded subtotal, discount, tax and total; what is paid and the tips collected; `balanceDue`; tip options (15/18/20/25% of subtotal) and the chosen tip; per-person split amounts; and, for the tenders the cashier is laying out, their total, what is still `outstanding`, the "pay rest" fill for each, the charge each will make (the tip rides the first) and cash `change`. The client renders these numbers and never derives money itself.

`selected` (line ids) splits the lines into `selectedTotal` / `unselectedTotal` for pay-by-item.

#### Signature

```http
GET /storefront/pos/tab/{id}/breakdown (id: string, tip?: number, tipPercent?: number, split?: integer, pending?: string, tendered?: string, selected?: string) -> { order, breakdown }
```

#### Access

Public — no credentials required.

#### Notes

- Anonymous: StorefrontController is `@PublicRoute()` at class level and this handler does not override it.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/settle`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |
| `tip` | query | number | — | Tip amount. Ignored when `tipPercent` is given. |
| `tipPercent` | query | number | — | Tip as a percent of the subtotal. |
| `split` | query | integer | — | Split the balance evenly this many ways (2 or more). |
| `pending` | query | string | — | Comma list of tender amounts being laid out. |
| `tendered` | query | string | — | Comma list, parallel to `pending`: cash handed over for each, for change. |
| `selected` | query | string | — | Comma list of line ids for pay-by-item. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { order, breakdown } |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/close

**Close a paid POS tab**

`operationId: StorefrontController_closePosTab`

Completes a fully paid tab (the order is completed in person) and frees its service point: the table goes to `dirty` — or `available` with `markDirty: false` — and its current tab, party size, server and seated time are cleared.

Refused while anything is still owed, or while a sent item is still open at a station.

#### Signature

```http
POST /storefront/pos/tab/{id}/close (id: string, body) -> The completed order and the freed service point
```

#### Access

Public — no credentials required.

#### Notes

- Anonymous: StorefrontController is `@PublicRoute()` at class level and this handler does not override it.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | TAB_CLOSED | Tab is already <status> | The tab is completed, cancelled, refunded or returned. | — |
| `409` | ITEMS_OPEN | <n> sent items are still open at the station (<names>). Close the tab once the ticket is done. | A sent item's station task is not done. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/settle`
- `POST /storefront/pos/tab/{id}/release-service-point`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The completed order and the freed service point |
| `400` | Tab is already <status> — The tab is completed, cancelled, refunded or returned. |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `409` | <n> sent items are still open at the station (<names>). Close the tab once the ticket is done. — A sent item's station task is not done. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/retry-accounting

**Retry the journal for a settled POS tab**

`operationId: StorefrontController_retryPosAccounting`

When settling a tab could not post its sale and payment journals (the tab's `posAccounting.status` is `needs_attention`), this replays the saved accounting snapshot. Idempotent: journals already posted are not posted again. No payment is changed.

#### Signature

```http
POST /storefront/pos/tab/{id}/retry-accounting (id: string) -> The order and its accounting status
```

#### Access

Public — no credentials required.

#### Notes

- A failure while posting is recorded in `accounting.issues` with `status: needs_attention`, not raised.
- Anonymous: StorefrontController is `@PublicRoute()` at class level and this handler does not override it.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `409` | NO_SNAPSHOT | This historical tab has no saved accounting snapshot; no payment or journal was changed | A tab settled before accounting snapshots existed. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/retry-stock`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The order and its accounting status |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `409` | This historical tab has no saved accounting snapshot; no payment or journal was changed — A tab settled before accounting snapshots existed. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/retry-stock

**Retry the stock deduction for a settled POS tab**

`operationId: StorefrontController_retryPosStock`

When settling a fully paid tab could not deduct stock (the saved stock intent is `needs_attention`), this replays it and posts the cost of goods. Already complete intents are returned unchanged. Refused if the sold items or location changed after payment, or the sale was refunded.

#### Signature

```http
POST /storefront/pos/tab/{id}/retry-stock (id: string) -> The order and its stock status
```

#### Access

Public — no credentials required.

#### Notes

- Problems resolving items, the location or posting COGS are recorded in `stock.issues` rather than raised.
- Anonymous: StorefrontController is `@PublicRoute()` at class level and this handler does not override it.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `409` | NO_STOCK_INTENT | This order has no saved POS stock intent; historical sales are not deducted automatically | The tab has no saved stock intent. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/retry-accounting`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The order and its stock status |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `409` | This order has no saved POS stock intent; historical sales are not deducted automatically — The tab has no saved stock intent. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/print-check

**Print a pre-payment check**

`operationId: StorefrontController_printPosCheck`

Builds an ESC/POS check from the tab and dispatches it to the location's configured printer via the hub-agent — the restaurant flow where a customer asks for the check before paying.

The printer is read from `location.data.pos.checkPrinter`, falling back to `receiptPrinter`. Use `GET /storefront/pos/tab/{id}/check-payload` instead when the client drives its own printer.

#### Signature

```http
POST /storefront/pos/tab/{id}/print-check (id: string) -> Dispatch result from the hub-agent
```

#### Access

Public — no credentials required.

#### Notes

- Requires a configured printer and a reachable hub-agent at the location; neither is validated before dispatch.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/pos/tab/{id}/check-payload`
- `POST /storefront/pos/tab/{id}/print-receipt`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Open POS tab `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | Dispatch result from the hub-agent |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/print-receipt

**Print or reprint a receipt**

`operationId: StorefrontController_printPosReceipt`

Prints the receipt for a tab through the location's printer. Works in any payment state — the tender section is taken from the most recent entry in `payments[]` — so it serves both the moment of sale and a later reprint of an old receipt.

#### Signature

```http
POST /storefront/pos/tab/{id}/print-receipt (id: string, copy?: string) -> Dispatch result from the hub-agent
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `GET /storefront/pos/tab/{id}/receipt-payload`
- `POST /storefront/pos/tab/{id}/send-receipt`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |
| `copy` | query | "customer" \| "merchant" | — | Selects the matching copy template. Omit to let the resolver use whichever copy the operator configured. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | Dispatch result from the hub-agent |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/pos/tab/{id}/receipt-payload

**Get receipt print primitives**

`operationId: StorefrontController_buildPosReceiptPayload`

Runs the same template resolution and render pipeline as `print-receipt` — org setting, then factory default; Handlebars-style interpolation; auto-skip-empty; loop, two-column and divider primitives — but returns the resolved lines instead of dispatching them.

This is the endpoint for clients that own their printer: a mobile app over Bluetooth, a kiosk over USB.

#### Signature

```http
GET /storefront/pos/tab/{id}/receipt-payload (id: string, copy?: string) -> The resolved print primitives
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/print-receipt`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |
| `copy` | query | "customer" \| "merchant" | — | Selects the copy variant template. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The resolved print primitives |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## GET /storefront/pos/tab/{id}/check-payload

**Get check print primitives**

`operationId: StorefrontController_buildPosCheckPayload`

The pre-payment check equivalent of `receipt-payload` — renders the check template for the tab and returns the primitive lines for a client that drives its own printer.

#### Signature

```http
GET /storefront/pos/tab/{id}/check-payload (id: string) -> The resolved print primitives
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/print-check`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The resolved print primitives |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/send-receipt

**Email or SMS a receipt**

`operationId: StorefrontController_sendPosReceipt`

Sends the receipt through the org's notification pipeline, using the same template chain as order-confirmation mail (org → shared-org → factory default).

Recipients resolve in this order: an explicit `to`, `email` or `phone`, then the contact saved on the tab. The channel is inferred — email when an address is available, SMS otherwise — unless `mode` forces one.

#### Signature

```http
POST /storefront/pos/tab/{id}/send-receipt (id: string, body) -> Dispatch result per channel
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | NO_RECIPIENT | No recipient — pass `to`, `email`, or `phone`, or save a contact on the tab first. | Neither the body nor the tab supplies a usable address, so no channel could be selected. | Send `to`, `email` or `phone`, or attach a customer to the tab when opening it. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/print-receipt`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Request body

Recipient and channel. All fields optional when the tab has a saved contact.

```json
{}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | Dispatch result per channel |
| `400` | No recipient — pass `to`, `email`, or `phone`, or save a contact on the tab first. — Neither the body nor the tab supplies a usable address, so no channel could be selected. |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/split

**Split a POS tab**

`operationId: StorefrontController_splitPosTab`

Moves lines off a tab onto one or more new tabs. Two mutually exclusive modes — pass exactly one:

- **By item** — `itemIds` moves those specific lines to a single new tab. Returns `{ source, target }`.
- **By count** — `parts` (≥ 2) distributes the lines across N tabs by descending value using a greedy bin-pack, with the source keeping the largest bin. Returns `{ source, targets[] }`.

Already-fired lines keep their `taskId`, so kitchen tickets are never disturbed by a split — only the parent order id moves. Partial payments stay on the source tab.

#### Signature

```http
POST /storefront/pos/tab/{id}/split (id: string, body) -> The source tab plus the tab(s) created — `target` for a by-item split, `targets[]` for by-count
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Source order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | NOT_SPLITTABLE | Cannot split <status> order | The source tab is in a status that cannot be split. | Only an active tab can be split. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/settle`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Source tab `sk`. |

### Request body

Split instruction. Send `itemIds` or `parts`, not both.

```json
{
  "itemIds": [
    "li_7f3a",
    "li_8b2c"
  ],
  "alias": "Table 7b"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The source tab plus the tab(s) created — `target` for a by-item split, `targets[]` for by-count |
| `400` | Cannot split <status> order — The source tab is in a status that cannot be split. |
| `404` | Source order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/refund

**Refund a payment on a POS tab**

`operationId: StorefrontController_refundPosTabPayment`

Refunds one confirmed payment line, in full or in part, and records it on the tab.

- **Cash** (method `cash`, gateway manual/offline): records the cash already handed back — send `cashReturned: true` once the guest has it.
- **Card / gift card**: refunds through the payments refund first (Stripe back to the card, a gift card back onto the card that paid), then records it. If the provider refuses, nothing is recorded.

The payment line is kept and marked `refundedAmount`/`refunded`, a negative refund line is added, `amountPaid` goes down, and the tab becomes `refunded` once the whole sale is refunded. Net sales and tax are reversed in proportion (gift cards sold come back out of the gift card liability) and an `sf_refund` is written. If the journal cannot post, the refund is still recorded with `status: needs_attention` and the same call with the same `operationKey` replays it.

**Idempotent on `operationKey`**: a retry with the same key returns the first refund and never refunds twice; the key must match the same payment and amount.

#### Signature

```http
POST /storefront/pos/tab/{id}/refund (id: string, body) -> The tab and the refund record
```

#### Access

Public — no credentials required.

#### Notes

- Anonymous in the code as it stands: StorefrontController is `@PublicRoute()` at class level and this handler does not override it.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | OPERATION_KEY_REQUIRED | operationKey is required — a stable id for this refund (8–100 letters, digits, _ or -), reused if the call is retried | Missing or malformed `operationKey`. | — |
| `409` | KEY_MISMATCH | This refund key belongs to a different payment or amount | The key was used for another payment or amount. | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/settle`
- `GET /storefront/order/{id}/payments`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Request body

```json
{
  "transactionId": "TXN-8KQ2",
  "amount": 12.5,
  "operationKey": "rf_7Kq2M9xa",
  "reason": "Cold food"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The tab and the refund record |
| `400` | operationKey is required — a stable id for this refund (8–100 letters, digits, _ or -), reused if the call is retried — Missing or malformed `operationKey`. |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `409` | This refund key belongs to a different payment or amount — The key was used for another payment or amount. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/assign-service-point

**Assign a service point to a tab**

`operationId: StorefrontController_assignServicePoint`

Seats a tab at a table or counter, setting `servicePointId` and optionally renaming the tab. The service point must belong to the same location as the tab — a mismatch is rejected rather than silently seating a tab across locations.

#### Signature

```http
POST /storefront/pos/tab/{id}/assign-service-point (id: string, body) -> The updated tab
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | SERVICE_POINT_REQUIRED | servicePointId is required | `servicePointId` is missing. | Send the id of the service point to seat the tab at. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/release-service-point`
- `POST /storefront/service-point/{id}/status`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Request body

The service point to seat the tab at.

```json
{
  "servicePointId": "sp_t7",
  "alias": "Table 7"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated tab |
| `400` | servicePointId is required — `servicePointId` is missing. |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/pos/tab/{id}/release-service-point

**Release a tab's service point**

`operationId: StorefrontController_releaseServicePoint`

Clears `servicePointId` from the tab and frees the table or counter for the next party. The tab itself stays open — releasing a service point is not the same as closing out.

#### Signature

```http
POST /storefront/pos/tab/{id}/release-service-point (id: string, body) -> The updated tab, with no service point held
```

#### Access

Public — no credentials required.

#### Notes

- Releasing a tab that holds no service point is a no-op, not an error.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/assign-service-point`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | POS tab `sk`. |

### Request body

Optional release details.

```json
{}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated tab, with no service point held |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/service-point/{id}/status

**Set a service point status**

`operationId: StorefrontController_setServicePointStatus`

Updates the state of a table or counter directly — for example marking it dirty, out of service, or ready. This acts on the `service_point` record itself, independently of any tab seated there.

#### Signature

```http
POST /storefront/service-point/{id}/status (id: string, body) -> The updated service point
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | STATUS_REQUIRED | status is required | `status` is missing from the body. | Send the new status value. |
| `404` | SERVICE_POINT_NOT_FOUND | Service point not found | No `service_point` in the org has that id. | Check the id. Note that the assign endpoint returns `400` for the same condition; this one returns `404`. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/pos/tab/{id}/assign-service-point`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | `service_point` id. |

### Request body

The new status.

```json
{
  "status": "dirty",
  "note": "Needs bussing"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated service point |
| `400` | status is required — `status` is missing from the body. |
| `404` | Service point not found — No `service_point` in the org has that id. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/order/{id}/items

**Add items to an order**

`operationId: StorefrontController_addItemsToOrder`

Appends lines to an open tab and recomputes `productCount`, `productSubtotal`, `subtotal`, `tax` and `amount`. Each item is resolved against the catalog by `sku`, so pricing and titles come from the product record rather than the client.

#### Signature

```http
POST /storefront/order/{id}/items (id: string, body) -> The updated order with recomputed totals
```

#### Access

Public — no credentials required.

#### Notes

- Adding lines does not fire them. Send them to a workflow with `POST /storefront/order/{id}/fire`.
- POS gate: adding lines checks the whole-register gate for the operator, then — for a product with `restriction.ageRestricted` / `restriction.permissionScope` (e.g. alcohol) — the gate with that `permissionScope`. A refusal is 423 `readiness_block` naming the product (`sku`, `productName`, `permissionScope`). Lines carry `permissionScope` and any `readinessOverride`; warnings return as `readiness.warnings`.
- Workforce Readiness: with no requirement rules this endpoint behaves exactly as before. `warn` never stops anything — it comes back as `readiness.warnings` in the response.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | ORDER_NOT_OPEN | Cannot add items to <status> order | The order is in a status that no longer accepts lines. | Open a new tab instead of amending a closed one. |
| `423` | READINESS_BLOCK | <name> can't sell <product> (alcohol): <requirement titles>. | A requirement rule whose `enforcement.pos` effect is block (or override-with-reason) is unmet for this person, and no override is active. | Show `message` and `reasons`. A location manager or HR can resend the same request with `override: { reasonCode, reason, expiresAt }` — it is written to the readiness ledger with the approver and expiry. The person’s own override is refused. |
| `403` | OVERRIDE_FORBIDDEN | Sign in as a location manager or HR to override. | The caller may not approve an override (no caller, the person themselves, or only their own supervisor). | — |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/order/{id}/fire`
- `POST /storefront/pos/tab/{id}/settle`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Order `sk`. |

### Request body

The lines to add.

```json
{
  "items": [
    {
      "sku": "DRK-COLA-330",
      "quantity": 2
    }
  ]
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The updated order with recomputed totals |
| `400` | Cannot add items to <status> order — The order is in a status that no longer accepts lines. |
| `403` | Sign in as a location manager or HR to override. — The caller may not approve an override (no caller, the person themselves, or only their own supervisor). |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `423` | <name> can't sell <product> (alcohol): <requirement titles>. — A requirement rule whose `enforcement.pos` effect is block (or override-with-reason) is unmet for this person, and no override is active. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /storefront/order/{id}/fire

**Fire order items to a workflow**

`operationId: StorefrontController_fireOrderItems`

Sends selected lines to a preparation workflow — kitchen, bar, lab, prep — and stamps the resulting `taskId` onto each fired line.

**`taskId` is the definition of "sent".** Lines that already carry one are skipped, which makes the endpoint safe against double-clicks and stale client caches. If *every* requested line was already fired the request fails rather than silently doing nothing, so the operator gets told.

#### Signature

```http
POST /storefront/order/{id}/fire (id: string, body) -> The order with `taskId` stamped on the newly fired lines
```

#### Access

Public — no credentials required.

#### Notes

- Partial success is normal: unfired lines in `itemIds` are fired and already-fired ones are skipped, with no error.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | ORDER_NOT_FOUND | Order not found | No `sf_order` in the org has the given `sk`. | Confirm the tab `sk`. `GET /storefront/pos/tabs` lists the open tabs for a location. |
| `400` | ITEM_IDS_REQUIRED | itemIds required | `itemIds` is missing or empty. | Send at least one line id. |

Plus the standard platform errors: `429`, `500`.

#### See also

- `POST /storefront/order/{id}/items`
- `GET /storefront/order/{id}`

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `id` | path | string | yes | Order `sk`. |

### Request body

Which lines to fire, and where.

```json
{
  "itemIds": [
    "li_7f3a",
    "li_8b2c"
  ],
  "workflowName": "kitchen-pipeline"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The order with `taskId` stamped on the newly fired lines |
| `400` | itemIds required — `itemIds` is missing or empty. |
| `404` | Order not found — No `sf_order` in the org has the given `sk`. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

