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:
| Status | Retry? |
|---|---|
500, 502, 503, 504 | Yes, 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. |
401 | Once, after fetching a fresh token. Then fail. |
400, 403, 404 | Never. 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
| Cadence | Fits |
|---|---|
| Nightly full reload | Almost everyone. Simple, complete, catches deletions. |
| Hourly during an event | Live dashboards. Full-reload Tickets, incremental TicketUsages. |
| Weekly | Reference 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
- ☐
$selectnames only the columns you load - ☐
SaleIsTest eq falseapplied 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 - ☐
CurrencyShortloaded alongside every monetary column - ☐ Status tokens checked against Column values, not guessed
- ☐ Health checked before the run
- ☐ Row count compared against
$count=trueafter the run