Ir al contenido
Developers

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 ApiUser role and access to the shops.
  • The API host for the country you are integrating with.
  • The shop_code values 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

  • To exclusive, hence +1 day. Writing 23:59:59 eats 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. ShopCode repeats, and every row carries its shop_code.
  • Different retries for 429 and 5xx. 429 is a wait that honours exponential backoff (there is no Retry-After); 5xx is exponential backoff too; other 4xx are 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

  1. Non-zero row count for days the shops were open.
  2. Every shop present. Ten configured, eight in the file means two are missing.
  3. 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