Export daily sales
A nightly job that pulls yesterday's sales for every shop into a CSV, with full pagination and retries
Updated on 2026-08-16For: Beginners · Developers
The most common integration and the best one to start with: every night, pull yesterday's sales for every shop into a file another process consumes.
What follows handles the three things people forget: full pagination, token expiry, and retries.
Before you start
- A BistroWeb user with the
ApiUserrole and access to the shops. - The API host for the country you are integrating with.
- The
shop_codevalues for those shops.
Try it against preproduction first: same endpoints as production, no real data.
The script
"""Export yesterday's sales for every configured shop to a CSV file."""
import csv
import os
import time
from datetime import date, datetime, timedelta
from decimal import Decimal
import requests
HOST = os.environ["BISTRO_HOST"]
USERNAME = os.environ["BISTRO_USERNAME"]
PASSWORD = os.environ["BISTRO_PASSWORD"]
SHOP_CODES = [int(code) for code in os.environ["BISTRO_SHOP_CODES"].split(",")]
PAGE_SIZE = 500
def get_token(session: requests.Session) -> str:
response = session.post(
f"https://{HOST}/api/v1.0/Token",
json={"username": USERNAME, "password": PASSWORD},
timeout=30,
)
if response.status_code == 401:
raise SystemExit("Wrong username or password.")
if response.status_code == 403:
raise SystemExit("The user is missing the ApiUser role in BistroWeb.")
response.raise_for_status()
return response.json()["token"]
def request_with_retry(session, url, params, headers, attempts=5):
for attempt in range(attempts):
response = session.get(url, params=params, headers=headers, timeout=60)
if response.status_code == 429:
time.sleep(min(60, 2 ** attempt)) # the API sends no Retry-After
continue
if response.status_code >= 500:
time.sleep(2 ** attempt)
continue
response.raise_for_status()
return response
raise RuntimeError(f"Could not fetch {url} after {attempts} attempts")
def fetch_sales(session, token, shop_codes, window_from, window_to):
"""Walk EVERY page; the cursor marks the end."""
params = {
"ShopCode": shop_codes, # repeated once per shop
"From": window_from.isoformat(timespec="seconds"),
"To": window_to.isoformat(timespec="seconds"),
"Limit": PAGE_SIZE,
}
headers = {"Authorization": f"Bearer {token}"}
while True:
response = request_with_retry(
session, f"https://{HOST}/api/v1.0/sales", params, headers
)
payload = response.json(parse_float=Decimal)
yield from payload["data"]
cursor = payload.get("next_page")
if not cursor:
return
params["Page"] = cursor
def main() -> None:
yesterday = date.today() - timedelta(days=1)
window_from = datetime.combine(yesterday, datetime.min.time())
window_to = window_from + timedelta(days=1) # To is exclusive
with requests.Session() as session:
token = get_token(session)
sales = list(fetch_sales(session, token, SHOP_CODES, window_from, window_to))
output = f"sales-{yesterday.isoformat()}.csv"
with open(output, "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["uuid", "shop_code", "created_at", "total", "currency"])
for sale in sales:
writer.writerow([
sale["uuid"], sale["shop_code"], sale["created_at"],
sale["total"], sale["currency"],
])
print(f"{len(sales)} sales written to {output}")
if __name__ == "__main__":
main()
The decisions behind it
Toexclusive, hence+1 day. Writing23:59:59eats the last second of the day.- One token per run. It lasts two days; requesting it per page would be the expensive mistake.
Limit=500. Fewer calls, less time, less exposure to network failures.- All shops in one call.
ShopCoderepeats, and every row carries itsshop_code. - Different retries for
429and5xx.429is a wait that honours exponential backoff (there is noRetry-After);5xxis exponential backoff too; other4xxare not retried.
Atención: When to run it
Not at 00:05. Shops closing after midnight are still selling. Between 5 and 7 AM in the latest shop's time zone is safe.
Verifying the run
- Non-zero row count for days the shops were open.
- Every shop present. Ten configured, eight in the file means two are missing.
- Daily total against the register. See Accounting reconciliation.
Going further
For a table that stays current instead of a file, swap the fixed window for an
incremental one over updated_at with overlap, and upsert on uuid. See