# Panasonic POS integration

This guide is for Panasonic engineers connecting a Panasonic POS to Spoonity
loyalty and promotions. The POS sends Spoonity each check. Spoonity works out
the discounts and returns them item by item. When the check closes, Spoonity
records the sale in the customer's loyalty account.

The POS never calculates a Spoonity discount, never chooses a reward, and
never calls any other Spoonity API. Three calls cover the whole integration:
`check`, `close` and `void`.

**Base URL:** `https://panasonic.spoonity.com`

## Contents

1. [Status](#1-status)
2. [The flow](#2-the-flow)
3. [Authentication](#3-authentication)
4. [Identifiers](#4-identifiers)
5. [The check: items, modifiers and combos](#5-the-check-items-modifiers-and-combos)
6. [POST /partners/pos/check](#6-post-partnersposcheck)
7. [Applying the discounts](#7-applying-the-discounts)
8. [POST /partners/pos/close](#8-post-partnersposclose)
9. [POST /partners/pos/void](#9-post-partnersposvoid)
10. [Repeating calls safely](#10-repeating-calls-safely)
11. [Timeouts and failures](#11-timeouts-and-failures)
12. [Errors](#12-errors)
13. [Testing](#13-testing)
14. [Checklist](#14-checklist)

## 1. Status

| Item | Status |
|---|---|
| `check`, `close` and `void`, item-level discounts, combos, holds, repeat-safe calls | Live |
| `Authorization: Bearer` with the merchant's API key | Live |
| Rewards the customer picked in the app, found from the scanned code | Live |
| Spoonity posts every closed check to the loyalty program (points and reward redemption) | Live |
| Voiding a closed check also reverses it in the loyalty program | Live |

Everything in this guide is built and live. Testing on a Panasonic VM against
a test merchant is the next step (section 13).

## 2. The flow

1. **Before the till.** The customer opens the merchant's app and picks the
   reward they want to use. The app tells Spoonity directly. The POS is not
   involved and never sees a list of rewards.
2. **Scan.** At any point while the order is being taken, the cashier scans
   the customer's code from the app. The POS recognises it as a Spoonity code
   by its prefix (see [Identifiers](#4-identifiers)) and holds it on the
   check. No call is made at scan time.
3. **Pay Now.** The POS sends the whole check to `POST /partners/pos/check`
   with the scanned code. Spoonity finds the customer and the reward they
   picked, applies the merchant's promotions, holds the reward for this
   check, and returns each discount with the item it applies to.
4. **Cancel and change.** If the cashier cancels out of Pay Now, the POS
   removes the Spoonity discounts. No call is needed. When Pay Now is pressed
   again, the POS sends `check` again with the full new check, and that
   answer replaces the previous one.
5. **Close.** When the check is paid and closed, the POS sends
   `POST /partners/pos/close`. Spoonity answers at once, so the POS can
   finish. Spoonity then posts the sale to the loyalty program in the
   background: the customer earns points and the reward is used up.

Send `check` and `close` for **every** check, including checks with no code
scanned. A check with no code is priced for a guest: promotions open to
everyone still apply, and no points are earned.

```
Customer app        POS terminal                    Spoonity
     |                    |                              |
     | pick a reward ---------------------------------->| saved for today
     |                    |                              |
     | show code -------->| scan: code held on check     |
     |                    | Pay Now: POST /check ------->| find the pick, price, hold
     |                    |<-----------------------------| discounts by item
     |                    | cancel, change, Pay Now:     |
     |                    | POST /check ---------------->| re-price, replace
     |                    |<-----------------------------| discounts by item
     |                    | apply discounts, take payment|
     |                    | check closed: POST /close -->| commit, answer at once
     |                    |<-----------------------------| { committed: true }
     |                    |                              | post sale to loyalty
```

## 3. Authentication

Send the merchant's API key as a bearer token on every request:

```
Authorization: Bearer MERCHANT_API_KEY
```

- Spoonity provides the key when the merchant is set up. The key decides the
  merchant; a merchant id in the body is never read.
- The base URL and the key are the only settings that change per merchant.
- A missing or unknown key is `401 UNAUTHORIZED`.
- `X-Spoonity-POS-Key: MERCHANT_API_KEY` is also accepted, for POS software
  that cannot set `Authorization`.
- Keep the key in POS configuration, never in logs or receipts.
- `Content-Type: application/json` on every request. Unknown fields are
  rejected with `400`, so a misspelled field is never silently ignored.

## 4. Identifiers

| Field | Rule |
|---|---|
| `check_id` | The POS order number **prefixed with the business date**: `YYYYMMDD-<order number>`, for example `20261103-104202`. Letters, digits, `_ . -`, at most 60 characters, no colon. Spoonity needs it to be unique per store for as long as the check can be refunded, and the date prefix guarantees that. |
| `terminal_id` | Optional. Logged only. |
| `pos_transaction_id` | Your unique id for the closed transaction. Required on `close` and `void`. 1 to 128 characters. |
| `customer.customer_id` | The code scanned from the customer's app, exactly as scanned, prefix included. |

No store id is needed. Each store has its own API key, and the key tells
Spoonity which store (Spoonity vendor) the check belongs to.

**Recognising a Spoonity code.** Spoonity codes start with a fixed prefix
that Spoonity gives you during setup. Configure the POS to treat any scanned
code with that prefix as a loyalty code. What follows the prefix is
Spoonity's business: the POS sends the whole code and never interprets it.

## 5. The check: items, modifiers and combos

All money is **integer cents** (`1149` is $11.49) and before tax. A decimal
is a `400`. Spoonity does not calculate tax.

**Items.** One line per item on the check:
`{ line_id, sku, name?, price, quantity, categories?, modifiers?, discounts? }`.

- `line_id` is unique within the check. Use the same ids on every `check`
  for that check and on `close`.
- `sku` is the item's PLU.
- `price` is the unit price the item sold at, without modifiers.

**Modifiers.** List each modifier on the item it belongs to, with its own
unit `price` and `quantity`: `{ id, name?, price, quantity }`. A modifier
with no charge has `price: 0`.

**Combos.** Send a combo as one line with its parts in `components`:

| Field | Notes |
|---|---|
| `price` | The combo price, per combo. |
| `quantity` | Number of combos. |
| `components[].component_id` | Your id for the part, unique within the combo. Spoonity uses it to say which part a discount applies to. |
| `components[].sku` | The part's PLU. |
| `components[].price` | The part's **a la carte** price. It is not charged; Spoonity uses it to split the combo price across the parts. |
| `components[].quantity` | Units of the part in one combo. |
| `components[].modifiers` | Modifiers on that part. A combo line carries no modifiers of its own. |

Spoonity splits the combo price across the parts in proportion to their a la
carte prices, and the parts always add up to the combo price exactly. A
reward on a part inside a combo takes off that part's share of the combo
price, not its a la carte price.

**Your own discounts.** Discounts the POS already applied (employee, senior,
manager) go on the line as `discounts: [{ id, name?, amount }]`, whole-line
cents. By default Spoonity does not add its own item discount to a line you
already discounted, and order-level discounts are calculated after yours, so
the same money is never discounted twice.

**Limits** (a request over any of them is a `400`):

- 200 lines per check, 2000 units per check (each part of a combo counts),
  1 to 1000 units per line. Leave a line off rather than sending
  `quantity: 0`.
- Any single amount at most 100,000,000 cents, never negative.
- Per line or part: at most 50 `modifiers`, 50 `categories`, 50 `discounts`
  and 50 `components`.
- Ids are 1 to 128 characters, `name` fields at most 256.

## 6. POST /partners/pos/check

Send when Pay Now is pressed, and again every time Pay Now is pressed after
a change.

| Field | Type | Notes |
|---|---|---|
| `check_id` | string, required | See [Identifiers](#4-identifiers). |
| `terminal_id` | string | Logged only. |
| `customer` | object | `{ "customer_id": "<scanned code>" }`. Omit when no code was scanned. |
| `codes` | string[] | Coupon or promo codes the cashier entered or scanned. At most 50. |
| `lines` | array, required | See [section 5](#5-the-check-items-modifiers-and-combos). |

Example: a combo with a large drink, and a cookie. The customer picked a
free taters reward in the app, and the merchant runs $5 off checks of $12 or
more.

```json
{
  "check_id": "20261103-104202",
  "terminal_id": "02",
  "customer": { "customer_id": "SPN6040123412341234" },
  "lines": [
    { "line_id": "101", "sku": "2PCCOMBO", "name": "2 Pc Combo", "price": 1149, "quantity": 1,
      "components": [
        { "component_id": "101-1", "sku": "2PC", "name": "2 Pc Chicken", "price": 899, "quantity": 1 },
        { "component_id": "101-2", "sku": "TATERS", "name": "Taters", "price": 399, "quantity": 1 },
        { "component_id": "101-3", "sku": "FOUNTAIN", "name": "Fountain Drink", "price": 279, "quantity": 1,
          "modifiers": [{ "id": "LRG", "name": "Large", "price": 50, "quantity": 1 }] }
      ] },
    { "line_id": "102", "sku": "COOKIE", "name": "Cookie", "price": 199, "quantity": 1 }
  ]
}
```

Response `200`:

```json
{
  "evaluation_id": "2112901:20261103-104202",
  "check_id": "20261103-104202",
  "customer": {
    "customer_id": "2114003",
    "balances": [{ "currency_id": "loyalty", "balance": 4200 }]
  },
  "subtotal": 1398,
  "subtotal_discount": 791,
  "total": 607,
  "discounts": [
    { "pos_reference": "SPN-FREETATERS", "offer_id": "reward_free_taters", "name": "Free Taters (reward)",
      "amount": 291, "scope": "item", "line_id": "101", "component_id": "101-2", "quantity": 1 },
    { "pos_reference": "SPN-5OFF", "offer_id": "promo_5_off", "name": "$5 off $12",
      "amount": 500, "scope": "order",
      "allocations": [
        { "line_id": "101", "component_id": "101-1", "amount": 296 },
        { "line_id": "101", "component_id": "101-3", "amount": 114 },
        { "line_id": "102", "amount": 90 }
      ] }
  ],
  "offers": [
    { "offer_id": "reward_free_taters", "name": "Free Taters (reward)", "status": "applied",
      "source": { "type": "redemption", "vendor_perk_redemption_id": 501 },
      "pos_reference": "SPN-FREETATERS", "discount_total": 291 },
    { "offer_id": "promo_5_off", "name": "$5 off $12", "status": "applied",
      "source": { "type": "promotion_code" }, "pos_reference": "SPN-5OFF", "discount_total": 500 }
  ],
  "receipt_lines": ["Free Taters (reward) applied, you saved $2.91", "$5 off $12 applied, you saved $5.00"],
  "redemption_id": "ored_5a58c78e-fbb4-4674-8bc5-827d72884dea",
  "hold_expires_at": "2026-11-03T17:45:00.000Z"
}
```

- `subtotal` is the check before discounts. `subtotal_discount` is the sum of
  every Spoonity discount. `total` is `subtotal`, minus your own line
  discounts, minus `subtotal_discount`. All before tax.
- `offers[]` lists every offer the merchant has and what happened to it:
  `applied`, `applicable` (would apply if selected), `skipped` or
  `inapplicable`. An offer that did not apply has a `reason` with a stable
  `code` (for example `MIN_SPEND_NOT_MET`, `RESERVED`, `LOCALLY_DISCOUNTED`)
  and a `message` a cashier can read.
- `receipt_lines` are ready to print. Treat them as display text: the
  wording can change.
- `customer` is `null` when no code was scanned or the code is not
  recognised. The check is then priced with the promotions open to
  everyone.
- `hold_expires_at`: the customer's reward is held for this check until then
  (15 minutes), so it cannot be used at another store at the same time. If
  payment is not finished by then, send `check` again.
- `external_discount_total` appears only when you sent your own line
  `discounts`.

## 7. Applying the discounts

Every Spoonity discount is applied to an item, so tax is calculated on the
discounted item. There can be more than one Spoonity discount on a check.

- **`scope: "item"`**: apply `amount` to the line `line_id`, or to the combo
  part `component_id` of that line when it is present.
- **`scope: "order"`**: apply each entry in `allocations` to its line (and
  combo part) for that entry's `amount`. The allocations always add up to
  the discount's `amount`.

How the POS represents the discount is up to you. One generic "Spoonity
Discount" item, sold as a negative-priced modifier on the item it discounts,
works for every Spoonity discount. You do not need an item per offer.
`pos_reference` identifies the offer for `close` and for reporting.

## 8. POST /partners/pos/close

Send once the check is paid and closed, for every check, even when `check`
failed, timed out or was never sent.

| Field | Type | Notes |
|---|---|---|
| `check_id` | required | Same as on `check`. |
| `pos_transaction_id` | string, required | Your unique id for this closed transaction. A repeated `close` with the same id is answered without doing anything twice. |
| `terminal_id` | string | Logged only. |
| `closed_at` | ISO 8601 with an offset (`Z` or `+hh:mm`) | When the POS closed the check, in the store's local time with its offset. The 15-minute hold is judged at this time (accepted up to 24 hours back and 5 minutes ahead of Spoonity's clock), and it dates the sale in the loyalty program. |
| `receipt.lines` | array, required | The final lines, same shape and limits as on `check`. |
| `receipt.discounts` | array, required | The **Spoonity** discounts you applied: `{ pos_reference, amount, name?, line_id?, component_id? }`. For an order discount, send one entry per allocation or one entry for the whole amount. Empty array if you applied none. |
| `receipt.total` | integer cents, required | The receipt total before tax. |

Example, for the check above:

```json
{
  "check_id": "20261103-104202",
  "pos_transaction_id": "1042-20261103-104202",
  "terminal_id": "02",
  "closed_at": "2026-11-03T12:36:10-05:00",
  "receipt": {
    "lines": [
      { "line_id": "101", "sku": "2PCCOMBO", "name": "2 Pc Combo", "price": 1149, "quantity": 1,
        "components": [
          { "component_id": "101-1", "sku": "2PC", "name": "2 Pc Chicken", "price": 899, "quantity": 1 },
          { "component_id": "101-2", "sku": "TATERS", "name": "Taters", "price": 399, "quantity": 1 },
          { "component_id": "101-3", "sku": "FOUNTAIN", "name": "Fountain Drink", "price": 279, "quantity": 1,
            "modifiers": [{ "id": "LRG", "name": "Large", "price": 50, "quantity": 1 }] }
        ] },
      { "line_id": "102", "sku": "COOKIE", "name": "Cookie", "price": 199, "quantity": 1 }
    ],
    "discounts": [
      { "pos_reference": "SPN-FREETATERS", "amount": 291, "line_id": "101", "component_id": "101-2" },
      { "pos_reference": "SPN-5OFF", "amount": 296, "line_id": "101", "component_id": "101-1" },
      { "pos_reference": "SPN-5OFF", "amount": 114, "line_id": "101", "component_id": "101-3" },
      { "pos_reference": "SPN-5OFF", "amount": 90, "line_id": "102" }
    ],
    "total": 607
  }
}
```

Response `200`:

```json
{ "committed": true, "redemption_id": "ored_5a58c78e-fbb4-4674-8bc5-827d72884dea", "evaluation_id": "2112901:20261103-104202" }
```

Spoonity answers before it posts the sale to the loyalty program, so `close`
stays fast. The POS does not need to wait for, or check, the loyalty side.
The customer is the one scanned on the check's `check` calls; a check with no
scanned code is recorded but not posted, since there is no one to credit.

If what you applied differs from what `check` returned, Spoonity still
commits (the money has already left the till) and reports the difference in
a `mismatch` object with the expected and received references and totals.

`committed: false` is still a `200`, so the POS never retries it. The
`reason` says why:

| `reason` | Meaning |
|---|---|
| `NO_PENDING_EVALUATION` | Spoonity never priced this check, for example because the POS timed out and went ahead without a discount. No reward is used. The sale is still posted for points when an earlier `check` for it carried the scanned code. |
| `EVALUATION_EXPIRED` | The 15-minute hold had ended when the check closed (by `closed_at`). The reward was not used. The sale is still posted for points. |
| `EVALUATION_CANCELLED` | The check was voided before it closed. |
| `EVALUATION_ROLLED_BACK` | The transaction was already voided. |

## 9. POST /partners/pos/void

Optional. Use it if the merchant's POS can cancel a check after `check`, or
refund a sale that had Spoonity discounts. Partial refunds are not
supported.

```json
{ "check_id": "20261103-104202", "pos_transaction_id": "1042-20261103-104202" }
```

Response `200`:

```json
{ "voided": true, "redemption_id": "ored_5a58c78e-fbb4-4674-8bc5-827d72884dea", "evaluation_id": "2112901:20261103-104202", "state": "rolled_back" }
```

- **Open check** (`state: "cancelled"`): the hold is released and the
  customer's reward is available again. You do not need this for a cancel
  out of Pay Now: the next `check` replaces the previous answer.
- **Closed check** (`state: "rolled_back"`): the reward and any usage limits
  are restored, and Spoonity reverses the sale in the loyalty program.
- `state: "expired"`: the hold had already ended, so there was nothing to
  release.
- An unknown check answers
  `{ "voided": false, "redemption_id": null, "reason": "NO_PENDING_EVALUATION" }`.

## 10. Repeating calls safely

- `check` can be repeated. The same `check_id` always refers to
  the same check, and each call replaces the previous answer. If the new
  check no longer qualifies, the held reward is released.
- `close` can be repeated with the same `pos_transaction_id`. A retry gets
  the same answer and nothing is committed or posted twice.
- `void` can be repeated with the same `pos_transaction_id`. A void may use
  the same transaction id as the close.
- A closed check cannot be checked again (`409 CHECK_CLOSED`). A new sale
  needs a new `check_id`.
- After 10 restarts of one check (holds that expired, or voids while open),
  `check` answers `409 CHECK_REOPEN_LIMIT`; use a new `check_id`.

## 11. Timeouts and failures

- **Never block the cashier on Spoonity.** If `check` times out, returns a
  `5xx` or fails on the network, close the check without a Spoonity
  discount.
- **Timeout.** Spoonity aims to answer `check` in under 300 ms. Your
  existing loyalty timeout works; a shorter one makes the cashier wait less
  when something is wrong.
- **Always send `close`**, even after a failed or skipped `check`. It is how
  Spoonity records the sale and posts the customer's points. A `close` that
  never arrives means the customer earns no points for that sale.
- **Retrying `close`.** Both `close` and `void` are safe to repeat. If the
  POS can retry them on a `5xx` or network error, it should. If it cannot,
  `close` is still sent once and the cashier moves on.
- Do not retry a `4xx` except `409 OFFERS_RESERVED` (once) and `429
  RATE_LIMITED`, which you retry after the `Retry-After` interval.

## 12. Errors

Error bodies are `{ "error": { "code": "...", "message": "..." } }`.

| Status | `code` | When | POS action |
|---|---|---|---|
| 400 | `INVALID_REQUEST` | The body failed validation (`issues[]` says where): a decimal amount, a duplicate `line_id` or `component_id`, an unknown field, a colon in `check_id`, modifiers on a combo line, a limit exceeded. | Fix the request. Close without a discount. |
| 401 | `UNAUTHORIZED` | Missing or unknown API key. | Configuration problem. Close without a discount. |
| 409 | `CHECK_CLOSED` | `check` for a check that already closed or was refunded. | Use a new `check_id` for a new sale. |
| 409 | `CHECK_REOPEN_LIMIT` | The check was restarted too many times. | Use a new `check_id`. |
| 409 | `OFFERS_RESERVED` | Another check is using the same reward at that moment. Rare. | Send `check` once more, then close without a discount. |
| 413 | `PAYLOAD_TOO_LARGE` | The request body is over 256 KB. | Fix the request. Close without a discount. |
| 429 | `RATE_LIMITED` | Too many requests with this key. The `Retry-After` header gives the seconds to wait. | Slow down and retry after `Retry-After`. If it does not clear, close without a discount. |
| 503 | `OFFERS_UNAVAILABLE` | Spoonity could not read the merchant's promotions or look up the customer. | Close without a discount. |
| 500 | `INTERNAL` | An error on Spoonity's side. | Close without a discount. |

## 13. Testing

Spoonity provides a test merchant and its API key on
`https://panasonic.spoonity.com`, with a test customer code and rewards set
up for the scenarios below.

1. Guest check with no code: `check`, then `close`.
2. Code scanned, reward picked in the app: discount returned on the right
   item and applied, `close` commits.
3. Reward on a part of a combo: discount lands on that part.
4. Order discount: allocations applied to their items and add up.
5. Cancel out of Pay Now, change the order, Pay Now again: the second
   `check` replaces the first.
6. Spoonity unreachable: the check closes without a discount, and `close`
   is still sent.
7. Modifiers with and without a charge.
8. A POS discount (employee or manager) on a line next to a Spoonity
   discount.

## 14. Checklist

- [ ] Base URL and merchant API key in POS configuration, key sent as
      `Authorization: Bearer`.
- [ ] Spoonity code prefix configured as a loyalty code.
- [ ] `check_id` is `YYYYMMDD-<order number>`.
- [ ] `check` sent at every Pay Now, with the whole check.
- [ ] Combos sent with `components`; modifiers on their item or part.
- [ ] Every Spoonity discount applied to its item or part.
- [ ] `close` sent for every check, with the discounts actually applied.
- [ ] Amounts in integer cents, before tax.

## For AI coding assistants

This guide is also available as plain markdown at
[/docs/index.md](/docs/index.md), and as one file at
[/docs/llms-full.txt](/docs/llms-full.txt). An index for language models is
at [/llms.txt](/llms.txt).
