Skip to content

Building the extract

Turning the four steps of Getting started into a loader you can schedule and trust.

The loop

The entire protocol is: get a token, request the first page, follow @odata.nextLink until it is gone. In Python:

import requests

# Your ADITUS API base URL plus the product prefix.
BASE_URL = "https://<your-api-host>/api/ticketinghub"
IDENTITY = "https://<identity-host>"

def get_token(client_id, client_secret):
    r = requests.post(
        f"{IDENTITY}/connect/token",
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
            "scope": "ticketinghub-api",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def extract(token, dataset, select=None, filter=None):
    """Yields every row of a dataset, one page at a time."""
    params = {}
    if select:
        params["$select"] = ",".join(select)
    if filter:
        params["$filter"] = filter

    url = f"{BASE_URL}/odata/v1/{dataset}"
    headers = {"Authorization": f"Bearer {token}"}

    while url:
        r = requests.get(url, params=params, headers=headers, timeout=300)
        r.raise_for_status()
        body = r.json()

        yield from body["value"]

        # The next link is absolute and already carries every query option.
        url = body.get("@odata.nextLink")
        params = None

The important detail is in the last three lines: the next link already contains your $select and $filter, so pass params only on the first request. Re-appending them to the next link is the single most common bug when people write this loop.

Retry and resume

Next links are stable, so a failed page can simply be retried — you never have to restart the walk.

import time

def get_with_retry(url, headers, params=None, attempts=5):
    for attempt in range(attempts):
        try:
            r = requests.get(url, params=params, headers=headers, timeout=300)
            if r.status_code in (500, 502, 503, 504):
                raise requests.HTTPError(f"server error {r.status_code}")
            r.raise_for_status()
            return r
        except (requests.HTTPError, requests.ConnectionError, requests.Timeout):
            if attempt == attempts - 1:
                raise
            time.sleep(2 ** attempt)   # 1, 2, 4, 8 seconds

What to retry and what not to:

StatusRetry?
500, 502, 503, 504Yes, with backoff. A 500 is often a query that hit the 300-second timeout — narrowing $select usually fixes it permanently. A 502 or 504 can also be the gateway not reaching the API.
401Once, after fetching a fresh token. Then fail.
400, 403, 404Never. These are your bug, not a transient fault.

Token expiry mid-walk. A long extract can outlive its hour. Either refresh proactively when the token is close to expiring, or treat a single 401 as the signal to fetch a new token and retry the same page.

Full reload versus incremental

Full reload — the default, and usually the right answer:

rows = list(extract(token, "Tickets", select=[
    "TicketInternalId", "FaireventName", "ArticleName", "ArticleType",
    "TicketGrossPrice", "CurrencyShort", "SalePaymentStatus", "SaleIsTest",
]))

Load into a staging table, then swap it in atomically. At ~47 seconds for 196,000 tickets, a nightly full reload is simpler than any incremental scheme and immune to the deletion problem below.

Incremental, available for TicketUsages and Surveys, which both carry LastRefreshedAt:

watermark = read_watermark_from_your_warehouse()   # e.g. "2026-09-01T00:00:00Z"
rows = extract(token, "TicketUsages",
               filter=f"LastRefreshedAt gt {watermark}")

Read this before relying on a watermark. A hard-deleted row leaves nothing behind to filter on, so deletions never appear as changes. Any watermark-only pipeline drifts away from the source over time. Pair it with a periodic full reload — weekly is usually enough — or accept the drift knowingly.

Tickets and StatisticGroups have no watermark at all, so they are full-reload only.

Scheduling

CadenceFits
Nightly full reloadAlmost everyone. Simple, complete, catches deletions.
Hourly during an eventLive dashboards. Full-reload Tickets, incremental TicketUsages.
WeeklyReference data — StatisticGroups changes rarely.

Call GET {BASE_URL}/health first. It fails fast and clearly, instead of your job discovering the problem halfway through a 400-page walk.

A checklist before you call it done

  • $select names only the columns you load
  • SaleIsTest eq false applied in the extract, not left to each report
  • Next link followed without re-appending query options
  • Retry with backoff on 5xx; no retry on 4xx
  • Token refreshed on 401, not per page
  • CurrencyShort loaded alongside every monetary column
  • Status tokens checked against Column values, not guessed
  • Health checked before the run
  • Row count compared against $count=true after the run