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:
- The ePOS registers open tables with Teya as tabs. The card machine lists those tabs to the staff member.
- 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
makePaymentAndSubscribeflow) 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_REQUESTdoes not commit the customer to paying. The staff member can show the bill, review it, and close it.onBillHiddenfires when the bill comes off screen.- Payment requests move the tab to
PAYING. Split payments produce multiplePAY_REQUESTevents against the same tab, each withpaymentType = SPLIT. The tab transitions toCOMPLETEDonce the outstanding balance reaches zero. - Tabs can pause. If the customer steps away mid-payment or the staff member explicitly pauses,
onTabPausedfires.TAB_RESUMEDfollows when the staff member returns. Tabs idle for 24 hours are auto-closed by Teya. closeTabarchives a tab. It works onOPEN,PAUSED, andCOMPLETEDtabs; aPAYINGtab must settle (or cancel) its in-flight payment before it can be closed. Until called, the tab keeps surfacing inlistTabs.
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"
}
tabNameis the label shown on the card machine's tab list. POSLink does not enforce uniqueness.currencyis 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"
}
tabIdandterminalIdecho the values from the originating event.totalAmountis in the smallest currency unit (e.g.5000for £50.00).- Pass
printModelfor a structured itemised bill, orbillImagePath(absolute path) for a pre-rendered image. WhenbillImagePathis set the SDK uploads the image andprintModelis 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
tabContextby echoing the event's fields. The event names thempaymentType/paymentMethod; the request names themtype/methodundertabContext. amountandcurrencyon the request match the event'samountandcurrency.
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"
}
statusesis optional. When omitted ornullthe backend default applies (active tabs only:OPEN,PAYING,PAUSED). Pass"COMPLETED"or"CLOSED"to include archived tabs.afterandbeforeare opaque cursors from a previous page'snextCursor/prevCursor; they are mutually exclusive.limitis optional; the backend default applies whennull.
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.