Pay at Table

Pay at Table (PAT) lets a Teya payment card machine initiate payments against an open table (or any open order) that lives in your ePOS application. A staff member taps the table on the card machine; the card machine asks your ePOS for the bill, displays it to the customer, takes payment (in full or split), and notifies your ePOS when the payment lands.

This guide walks through the complete PAT integration on Windows with the Teya Unified ePOS SDK.

Pay at Table is available from Unified ePOS SDK v1.6.0 on Windows. The walkthrough below assumes the PosLink SDK has already been integrated and Setup is complete - see Getting Started.

When to use Pay at Table

The integration has two halves:

  1. The ePOS registers open tables with Teya as tabs. The card machine lists those tabs to the staff member.
  2. The ePOS subscribes to card machine-initiated events (bill requested, payment requested, payment completed, tab paused/resumed). It can answer bill requests with the bill, start the payment via the SDK when a payment request arrives, and close the tab once it is fully paid.

Pay at Counter (the regular makePaymentAndSubscribe flow) covers the simpler case: the customer comes to a counter to pay and the ePOS initiates the payment. Pay at Table fits restaurants, bars, hotels, and any setting where the card machine is mobile and the staff member, rather than the ePOS user, drives payment.

Vocabulary

Term Meaning
Tab An open table, ticket, or order in your ePOS. Each tab has an ePOS-chosen tabId and a display tabName.
Bill The current breakdown of items, total, and amount remaining for a tab. Your ePOS owns the bill data; Teya only relays it to the card machine on request.
Payment request A card machine-initiated request to pay against a tab. Carries an amount, a paymentType (FULL or SPLIT), and the originating terminalId.
Tab status OPEN, PAYING, PAUSED, COMPLETED, or CLOSED. Active tabs are OPEN, PAYING, PAUSED.

Lifecycle

        ┌───── ePOS ─────┐                  ┌─────── Teya ───────┐                ┌── Terminal ──┐
        │                │                  │                    │                │              │
        │ openTab(id,    │ ───────────────▶ │   tab registered   │                │              │
        │   name, "GBP") │                  │                    │                │              │
        │                │                  │                    │                │              │
        │                │                  │                    │ ◀───────────── │ staff taps │
        │                │                  │ SHOW_BILL_REQUEST │                │ tab on UI   │
        │   onShowBill   │◀── RPC stream ───│                    │                │              │
        │   Requested    │                  │                    │                │              │
        │                │                  │                    │                │              │
        │ respondToBill │ ───────────────▶ │     bill sent      │ ─────────────▶ │ bill shown │
        │ Request(total) │                  │                    │                │              │
        │                │                  │                    │                │              │
        │                │                  │                    │ ◀───────────── │ staff taps │
        │                │                  │    PAY_REQUEST     │                │ Pay         │
        │   onPay        │◀── RPC stream ───│                    │                │              │
        │   Requested    │                  │                    │                │              │
        │                │                  │                    │                │              │
        │ makePayment(   │ ───────────────▶ │ payment created   │ ─────────────▶ │ takes card │
        │   tabContext) │                  │                    │                │              │
        │                │                  │                    │                │              │
        │                │                  │                    │ ◀───────────── │ payment     │
        │                │                  │   TAB_COMPLETED    │                │ successful │
        │   onTab        │◀── RPC stream ───│                    │                │              │
        │   Completed    │                  │                    │                │              │
        │                │                  │                    │                │              │
        │ closeTab(id)   │ ───────────────▶ │    tab archived    │                │              │
        └────────────────┘                  └────────────────────┘                └──────────────┘

Key points about the lifecycle:

  • Tabs are opened by the ePOS. Teya does not create tabs on its own; the card machine can only show tabs the ePOS has registered.
  • The card machine drives the conversation. All bill, pay, pause, and resume events originate from the card machine; the ePOS reacts to them rather than initiating them.
  • SHOW_BILL_REQUEST does not commit the customer to paying. The staff member can show the bill, review it, and close it. onBillHidden fires when the bill comes off screen.
  • Payment requests move the tab to PAYING. Split payments produce multiple PAY_REQUEST events against the same tab, each with paymentType = SPLIT. The tab transitions to COMPLETED once the outstanding balance reaches zero.
  • Tabs can pause. If the customer steps away mid-payment or the staff member explicitly pauses, onTabPaused fires. TAB_RESUMED follows when the staff member returns. Tabs idle for 24 hours are auto-closed by Teya.
  • closeTab archives a tab. It works on OPEN, PAUSED, and COMPLETED tabs; a PAYING tab must settle (or cancel) its in-flight payment before it can be closed. Until called, the tab keeps surfacing in listTabs.

Connection model

Tab events stream over a single JSON-RPC call. subscribeTabEvents opens the stream and the SDK emits each tab event as a JSON-RPC notification (a message with no id), each one carrying a result.type such as subscribeTabEvents->onShowBillRequested. unsubscribeTabEvents ends the stream, which then completes with a final response carrying the original request id and a subscribeTabEvents->onConnectionStateChange result whose state is DISCONNECTED. Transient disconnects (network drops, brief backend restarts) are retried automatically with exponential backoff, so no explicit handling is needed. DISCONNECTED is also delivered when the SDK drops the stream because the linked store was cleared; in that case the setup flow needs to run again before resubscribing.

What is NOT covered here

  • PosLink SDK basics (initialization, store linking, regular payments) covered in POSLink Getting Started and Make Payment.
  • Designing the merchant-side UI of the ePOS. The SDK is headless for PAT; table state, bill rendering, and payment summaries live on the ePOS side.
  • Android / iOS / direct REST integration. Separate guides cover those platforms.

Enable Pay at Table on the store

PAT is opt-in at the store level. setPayAtTableEnabledOnStore with enable: true enables it for the linked store; persisting a flag client-side avoids repeating the call on every launch.

Request:

{
  "jsonrpc": "2.0",
  "method": "setPayAtTableEnabledOnStore",
  "params": {
    "enable": true
  },
  "id": "f3c0a1d4-7e91-4f3a-9e1b-7c2d8a6f4e10"
}

Response:

Success

{
  "jsonrpc": "2.0",
  "result": {
    "type": "setPayAtTableEnabledOnStore->onResult",
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "f3c0a1d4-7e91-4f3a-9e1b-7c2d8a6f4e10"
}

failureReason is null and POSLink has accepted the new setting. Persist locally that PAT is enabled for this store.

setPayAtTableEnabledOnStore is a store-wide capability flag. Each individual card machine additionally keeps a local opt-out toggle, defaulting to ON. Disabling the store flag does not flip the local toggle, and enabling it does not override a card machine that the merchant opted out individually. Disabling is an entry-point gate only; any PAT session already in progress runs to completion.


Open a tab

openTab registers a table or open order so the card machine can list it and act on it. The ePOS picks the tabId; Teya stores the metadata and routes card machine-initiated events back to the ePOS.

Request:

{
  "jsonrpc": "2.0",
  "method": "openTab",
  "params": {
    "tabId": "ORDER-2026-001",
    "tabName": "Table 5",
    "currency": "GBP"
  },
  "id": "9b7f1d22-4c54-4e71-8e2a-86a4f3e2b9c1"
}
  • tabName is the label shown on the card machine's tab list. POSLink does not enforce uniqueness.
  • currency is an ISO 4217 code; all payments on this tab must use it.

Response:

Success

{
  "jsonrpc": "2.0",
  "result": {
    "type": "openTab->onResult",
    "tab": {
      "tabId": "ORDER-2026-001",
      "tabName": "Table 5",
      "status": "OPEN",
      "totalAmount": null,
      "totalPaid": null,
      "remaining": null,
      "currency": "GBP",
      "showingBillTerminalId": null,
      "billFileId": null,
      "paymentRequests": null,
      "createdAtEpochMillis": 1733241600000,
      "updatedAtEpochMillis": 1733241600000
    },
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "9b7f1d22-4c54-4e71-8e2a-86a4f3e2b9c1"
}

The returned tab carries the freshly opened tab. Detail-only fields (paymentRequests, billFileId, totals) are null at creation; they populate as the tab is used.


Subscribe to tab events

PAT is event-driven. subscribeTabEvents is a single streaming JSON-RPC call: send it once, and the SDK emits tab events back as JSON-RPC notifications (messages with no id). The ePOS branches on each notification's result.type to decide how to react. As described in Getting Started, notifications can be detected in the read loop by checking for a missing id field.

Request:

{
  "jsonrpc": "2.0",
  "method": "subscribeTabEvents",
  "id": "0b3a7c8d-2a4f-4d6c-9b3a-6c1e8f5d4a20"
}

While the stream is active the SDK emits notifications, each one with a different result.type value. A representative bill-requested notification looks like:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "subscribeTabEvents->onShowBillRequested",
    "tabId": "ORDER-2026-001",
    "terminalId": "term-001"
  }
}

Connection-state notifications use the same envelope:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "subscribeTabEvents->onConnectionStateChange",
    "state": "CONNECTED"
  }
}

When the underlying stream actually drops (because unsubscribeTabEvents was called or the linked store was cleared), the SDK emits one final response carrying the original request id and a DISCONNECTED connection state, then stops emitting:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "subscribeTabEvents->onConnectionStateChange",
    "state": "DISCONNECTED"
  },
  "id": "0b3a7c8d-2a4f-4d6c-9b3a-6c1e8f5d4a20"
}

CONNECTING only appears on transient drops while the SDK reconnects; CONNECTED is emitted once the stream is established. Only one subscription may be active at a time; calling subscribeTabEvents again while one is live is rejected with an invalid-state error (see Getting Started for the error envelope).

All sub-event types at a glance:

result.type Fires when
subscribeTabEvents->onShowBillRequested Card Machine asks for the bill so it can be shown to the customer.
subscribeTabEvents->onPayRequested Card Machine wants to take a payment (full or split) against the tab.
subscribeTabEvents->onPaymentProgress Best-effort intermediate update for an in-flight payment.
subscribeTabEvents->onPaymentCompleted One payment request reached a final state. Split payments fire this once per payment.
subscribeTabEvents->onTabPaused Card Machine paused a split mid-flow (e.g. customer stepped away).
subscribeTabEvents->onTabResumed A paused tab resumed because a card machine initiated a new payment against it.
subscribeTabEvents->onTabCompleted The tab's outstanding balance reached zero. You still need to call closeTab.
subscribeTabEvents->onBillHidden Bill came off screen without payment (card machine exited or timed out).
subscribeTabEvents->onConnectionStateChange CONNECTED ↔ CONNECTING ↔ DISCONNECTED lifecycle of the underlying stream.

To stop the stream, send:

{
  "jsonrpc": "2.0",
  "method": "unsubscribeTabEvents",
  "id": "47ae2f3c-9b1d-4c8a-95e2-2a3b7c5d8f01"
}

The SDK responds with an acknowledgement (unsubscribeTabEvents->ack) and the streamed subscribeTabEvents call then completes with its final DISCONNECTED response described above. The call is a no-op if no subscription is active.


Respond to bill requests

When a subscribeTabEvents->onShowBillRequested notification arrives, respondToBillRequest sends the bill back so the card machine can render it. It accepts either a printModel (structured receipt rows, using the same row schema as printCustomTemplate) or a billImagePath (absolute path to a pre-rendered PNG/JPEG). When the request cannot be honoured (order missing, bill already settled, internal error), respondWithError rejects it with an error category and a message that the card machine surfaces to the staff member.

Incoming event:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "subscribeTabEvents->onShowBillRequested",
    "tabId": "ORDER-2026-001",
    "terminalId": "term-001"
  }
}

Respond with the bill

Request:

{
  "jsonrpc": "2.0",
  "method": "respondToBillRequest",
  "params": {
    "tabId": "ORDER-2026-001",
    "terminalId": "term-001",
    "totalAmount": 5000,
    "currency": "GBP",
    "printModel": [
      { "rowType": "item", "item": { "elementType": "text", "text": "Sample bill", "bold": true, "align": "CENTER" } },
      { "rowType": "item", "item": { "elementType": "text", "text": "Tab: ORDER-2026-001", "align": "CENTER" } },
      { "rowType": "spacer" },
      {
        "rowType": "items",
        "items": [
          { "elementType": "text", "text": "TOTAL", "bold": true },
          { "elementType": "text", "text": "GBP 50.00", "bold": true, "align": "RIGHT" }
        ]
      }
    ]
  },
  "id": "f1a2b3c4-d5e6-47f8-89ab-1c2d3e4f5061"
}
  • tabId and terminalId echo the values from the originating event.
  • totalAmount is in the smallest currency unit (e.g. 5000 for £50.00).
  • Pass printModel for a structured itemised bill, or billImagePath (absolute path) for a pre-rendered image. When billImagePath is set the SDK uploads the image and printModel is not sent. The same payload drives both the on-screen view and the printed copy, so it should render legibly in both.

Response:

Success

{
  "jsonrpc": "2.0",
  "result": {
    "type": "respondToBillRequest->onResult",
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "f1a2b3c4-d5e6-47f8-89ab-1c2d3e4f5061"
}

POSLink accepted the bill. The terminal will display (and optionally print) it.

Alternative: pre-rendered bill image

For ePOSes that already render the bill as an image (PDFs converted to bitmaps, generated receipts, etc.), pass billImagePath instead of printModel. The value must be an absolute path to a PNG or JPEG on disk. The SDK uploads the file and printModel is ignored when billImagePath is supplied.

{
  "jsonrpc": "2.0",
  "method": "respondToBillRequest",
  "params": {
    "tabId": "ORDER-2026-001",
    "terminalId": "term-001",
    "totalAmount": 5000,
    "currency": "GBP",
    "billImagePath": "C:\\Bills\\ORDER-2026-001.png"
  },
  "id": "9b8a7c6d-5e4f-43a2-b1c0-d9e8f7a6b5c4"
}

Bill limits

Bills are rendered by the same pipeline as custom receipt printing, so the same limits apply.

Template limits - apply when you send printModel:

  • A template can have a maximum of 150 rows.
  • Before being sent to the card machine the template is converted to another JSON in which images are encoded as Base64 strings. This conversion increases the overall size, so the final JSON (including images) must not exceed 250 KB.

Image limits - apply when you send billImagePath:

  • The maximum height for the image is 10000 px.
  • The recommended width is 384 px. Wider images are scaled down to fit the printer width.
  • The image is converted to image data before being sent, and the total size of that data must not exceed 200 KB.

Limits that can be detected immediately return a JSON-RPC error with code -3 (ILLEGAL_ARGUMENT_EXCEPTION) and the reason in message. Limits detected by the backend are reported in respondToBillRequest->onResult with failureReason set to UNEXPECTED_ERROR and details in debugErrorMessage.

Reject the request

Request:

{
  "jsonrpc": "2.0",
  "method": "respondWithError",
  "params": {
    "tabId": "ORDER-2026-001",
    "terminalId": "term-001",
    "errorCode": "TABLE_NOT_FOUND",
    "message": "Tab no longer exists"
  },
  "id": "21fa9bd0-b8c7-4aa3-8c4d-94c0e2f6d7b3"
}

Response:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "respondWithError->onResult",
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "21fa9bd0-b8c7-4aa3-8c4d-94c0e2f6d7b3"
}

A null failureReason means POSLink accepted the rejection and the card machine will display message alongside the error code. On failure failureReason is one of NO_LINKED_STORE, NETWORK_ERROR, or UNEXPECTED_ERROR.

Error handling

errorCode values carried by respondWithError:

Code Meaning
TABLE_NOT_FOUND The tab/order no longer exists in the ePOS.
BILL_ALREADY_SETTLED The bill was already settled outside Pay at Table (cash at counter, refunded earlier, etc.).
EPOS_ERROR Generic ePOS-side failure: DB down, internal exception, anything not covered above.
REQUEST_REJECTED The ePOS explicitly rejects the request as a policy decision.

respondToBillRequest->onResult.failureReason values:

Reason Meaning
NO_LINKED_STORE The SDK has no linked store; re-running Setup restores it.
NETWORK_ERROR Could not reach Teya; backoff retry is a safe response.
TOTAL_LESS_THAN_PAID The supplied totalAmount is lower than the amount already paid on the tab; the bill needs to be recomputed.
TAB_IN_WRONG_STATE Show-bill TTL expired, tab not found, tab is COMPLETED/CLOSED, or terminalId mismatch; the request can be treated as cancelled.
UNEXPECTED_ERROR Anything else; debugErrorMessage carries details.

Take tab payments

When a subscribeTabEvents->onPayRequested notification arrives, makePaymentAndSubscribe (the same method covered in Make Payment) starts the payment. The values carried by the event become a tabContext object on the request, so the SDK can route the payment back to the originating card machine and tab. The context carries the tabId, the originating terminalId, the payment type (FULL or SPLIT), and the payment method (CARD or CASH).

Incoming event:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "subscribeTabEvents->onPayRequested",
    "tabId": "ORDER-2026-001",
    "terminalId": "term-001",
    "paymentType": "SPLIT",
    "paymentMethod": "CARD",
    "amount": 2500,
    "currency": "GBP"
  }
}

Start the payment, request:

{
  "jsonrpc": "2.0",
  "method": "makePaymentAndSubscribe",
  "params": {
    "transactionId": "tx-019851ef-29f2-70e8-800d-fdd875bfa0a4",
    "amount": 2500,
    "currency": "GBP",
    "useTeyaDefaultUi": true,
    "tip": null,
    "tabContext": {
      "tabId": "ORDER-2026-001",
      "terminalId": "term-001",
      "type": "SPLIT",
      "method": "CARD"
    }
  },
  "id": "8c2c3a3f-6f1c-4ed0-9f8b-2b4d6e8a0c12"
}
  • Build tabContext by echoing the event's fields. The event names them paymentType / paymentMethod; the request names them type / method under tabContext.
  • amount and currency on the request match the event's amount and currency.

Responses follow the standard makePaymentAndSubscribe->onPaymentStateChanged envelope documented in Make Payment. There is no separate payment-state subscription for tab payments; the existing intermediate-state notifications (messages with no id) and the final response (carrying the original request id) are the same surface, and tab payments succeed or fail through that same channel. In addition, the active subscribeTabEvents stream emits subscribeTabEvents->onPaymentProgress and subscribeTabEvents->onPaymentCompleted notifications for the same payment, so either surface can drive the UI.

The SDK uses tabContext.terminalId to route the payment, so tab payments succeed even when the ePOS is linked at the store level only (no specific terminal linked). The terminal that initiated the request is always the one that completes the payment.


Close the tab

closeTab archives a tab and removes it from the default listTabs results. It works on OPEN, PAUSED, and COMPLETED tabs; a PAYING tab must settle (or cancel) its in-flight payment before it can be closed. Teya also auto-closes tabs idle for 24 hours.

Request:

{
  "jsonrpc": "2.0",
  "method": "closeTab",
  "params": {
    "tabId": "ORDER-2026-001"
  },
  "id": "5d4c3b2a-1e9f-48d7-b6c5-a4f3e2d1c0b9"
}

Response:

Success

{
  "jsonrpc": "2.0",
  "result": {
    "type": "closeTab->onResult",
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "5d4c3b2a-1e9f-48d7-b6c5-a4f3e2d1c0b9"
}

The tab transitioned to CLOSED.

A closed tab is not deleted. getTab still returns it and listTabs with statuses: ["CLOSED"] still surfaces it, so payment history and receipts remain available.


List and get tabs

listTabs (cursor-paginated) request:

{
  "jsonrpc": "2.0",
  "method": "listTabs",
  "params": {
    "statuses": ["OPEN", "PAYING", "PAUSED"],
    "after": null,
    "before": null,
    "limit": 50
  },
  "id": "a1b2c3d4-e5f6-4708-89a0-1c2d3e4f5061"
}
  • statuses is optional. When omitted or null the backend default applies (active tabs only: OPEN, PAYING, PAUSED). Pass "COMPLETED" or "CLOSED" to include archived tabs.
  • after and before are opaque cursors from a previous page's nextCursor / prevCursor; they are mutually exclusive.
  • limit is optional; the backend default applies when null.

listTabs response:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "listTabs->onResult",
    "page": {
      "items": [
        {
          "tabId": "ORDER-2026-001",
          "tabName": "Table 5",
          "status": "OPEN",
          "totalAmount": 5000,
          "totalPaid": 0,
          "remaining": 5000,
          "currency": "GBP",
          "showingBillTerminalId": null
        }
      ],
      "nextCursor": "cursor-next",
      "prevCursor": null
    },
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "a1b2c3d4-e5f6-4708-89a0-1c2d3e4f5061"
}

Pass the nextCursor value into after on the next call to fetch the following page.

getTab (full detail including payment history) request:

{
  "jsonrpc": "2.0",
  "method": "getTab",
  "params": {
    "tabId": "ORDER-2026-001"
  },
  "id": "b2c3d4e5-f607-4819-9ab0-2d3e4f506172"
}

getTab response:

{
  "jsonrpc": "2.0",
  "result": {
    "type": "getTab->onResult",
    "tab": {
      "tabId": "ORDER-2026-001",
      "tabName": "Table 5",
      "status": "PAYING",
      "totalAmount": 5000,
      "totalPaid": 2500,
      "remaining": 2500,
      "currency": "GBP",
      "showingBillTerminalId": "term-001",
      "billFileId": null,
      "paymentRequests": [
        {
          "eposTransactionId": "tx-123",
          "status": "SUCCESSFUL",
          "amount": 2500,
          "currency": "GBP",
          "tip": null,
          "method": "CARD",
          "type": "SPLIT",
          "createdAtEpochMillis": 1733241600000,
          "updatedAtEpochMillis": 1733241700000,
          "transactionTimestampEpochMillis": 1733241650000,
          "metadata": null
        }
      ],
      "createdAtEpochMillis": 1733241500000,
      "updatedAtEpochMillis": 1733241700000
    },
    "failureReason": null,
    "debugErrorMessage": null
  },
  "id": "b2c3d4e5-f607-4819-9ab0-2d3e4f506172"
}

tab.paymentRequests lists every payment recorded against the tab. Field-level documentation for each model surfaced here lives in the auto-generated API Reference shipped alongside the Windows SDK.