Five POS integrations and one definition of a sale
bilz.ai computes daily profit from two sides: what a restaurant bought, read from invoices, and what it sold, read from the POS. The POS side sounds like the easy half, because every POS has an API and every API has a sales endpoint. We integrated Toast, Square, Clover, Lightspeed and Syrve, and each of the five has a different answer to the question of what a sale is. The product only works if we have one answer.
Authentication is a background job
Most of the five use OAuth, each with its own details: how long an access token lives, whether the refresh token expires, whether it rotates on use, and what happens when a restaurant owner revokes access from the POS side without telling anyone. The rest issue a token in exchange for credentials, with its own expiry.
Token refresh has to be a background job with monitoring. A token that expires at three in the morning, when the nightly import runs and nobody is awake, becomes a day of missing sales. We refresh ahead of expiry, store the result in the same transaction as the location it belongs to, and alert when a refresh fails twice. Revocation shows up as a 401 that no refresh fixes, and the product has to turn that into a message asking the owner to reconnect, so it does not become a silent gap.
Each POS also has its own idea of the unit of connection. One authorises a merchant with many locations. Another authorises one location at a time. The data model has to carry both, because a restaurant group with three sites on two POS systems is a normal customer.
Pagination and backfill
A new restaurant wants to see last month’s profit on the day it signs up, so the first import is a backfill of weeks or months of orders. Each API pages differently: cursors, offsets, date windows with a maximum width, and sometimes a limit on how far back the endpoint will look at all. Rate limits are per endpoint on some systems and per merchant on others.
The backfill runs as a queue of date windows per location, each small enough to finish inside the rate limit and the request timeout, with its progress stored so it can resume after a failure. A backfill that restarts from the beginning when it hits a limit at window forty never finishes.
The nightly import is the same code with a window of one day plus a lookback. Orders change after they close. A refund arrives two days later. A ticket left open at closing time is closed the next morning. We re-import the last few days every night and let the idempotency rules below sort out what changed.
What counts as a sale
This is the question the five systems answer differently, and the one that decides whether the profit number is right.
A voided ticket is not a sale, and some systems return it with a flag while others leave it out of the list and expose it elsewhere. A refund can be a negative order, a field on the original order, or a separate object linked to a payment, and it can be partial. A modifier can carry a price, and whether that price sits on the item or on the modifier changes what the item’s revenue looks like. A discount can apply to a line, a ticket or a category, as a percentage or an amount, and the system may or may not allocate it to lines for you. A service charge may be revenue to the restaurant or a pass-through to staff. Tips are never revenue, and some systems report them inside the payment total.
The product has to choose one definition and map each system onto it. For bilz.ai the definition is net sales by item: the item’s price after line discounts and its share of ticket discounts, excluding voids, net of refunds attributed to the item, excluding tips, with service charges handled as the restaurant configures them. Every mapping is written down per POS, and each one is tested against the end-of-day report the POS itself prints, because that report is what the owner believes.
Item-level mapping matters because the cost side is per recipe. Sales of a dish drive the ingredient usage that bilz.ai compares with stock, so a modifier that swaps an ingredient has to reach the recipe engine as a different dish.
Time zones and business days
A restaurant’s day does not end at midnight. A ticket closed at one in the morning belongs to the previous day’s service, and the owner compares the report with the drawer that was counted at closing. Each POS has a business-day setting, or a day-start time, or neither. Some return timestamps in UTC, some in the location’s time zone, and the field that says which is not always the one you expect.
We store every timestamp in UTC with the location’s time zone next to it, and compute the business day in our own code from the restaurant’s configured day start. That setting lives with the location, is editable, and recomputes history when it changes. The daylight-saving transition in spring and autumn happens inside the service window for a late-night venue, and the business-day logic has to survive it.
Failures at night
Imports run when the restaurant is closed, which is also when the POS vendors run their maintenance. Timeouts, 5xx responses, rate-limit responses with and without a retry header, and 200 responses with an empty body on a day with hundreds of orders have all happened.
The import treats every failure as retryable with backoff, up to a limit, and then as an alert. An empty day for a location that normally sells is itself a check: the import flags it rather than recording zero sales, because zero sales and a failed import produce the same number and very different profit reports.
Idempotent import
An order may be imported many times: in the backfill, in the nightly lookback, in a retry after a timeout, after a manual re-run. The import is keyed on the POS’s own order identifier plus the location, and every write is an upsert. Lines inside the order are keyed too, because a line can be voided after the order was first imported.
This gives us the freedom to re-run anything. A bug in the discount allocation is fixed by re-importing the affected window. Nobody writes a migration against our own copy of the data.
Reconciliation
Every night, after the import, the total we computed for each location and business day is compared with the end-of-day figure the POS reports through its own summary endpoint, or with the report the restaurant exports. Agreement within rounding means the mapping holds. Disagreement is logged with the difference and the day, and a persistent disagreement on one POS is nearly always a mapping change: a new discount type, a service-charge setting the owner changed, a vendor update that moved tips.
The reconciliation number is the one we watch. It is what lets us tell the owner that the sales side of the profit report matches the drawer, and that is the promise the whole product rests on.
If you are connecting a product to POS systems, or to any set of APIs that each define the same business object differently, the custom AI apps page describes how we build it, and you can write to us about the systems you have to connect.