Pagination
Explains client-side pagination for all Reporting API tables and pagination for the LMPI table, with sample scripts for each.
The Reporting API supports pagination, so you can send successive API requests and fetch a larger dataset. Client-side pagination with the skip and top options works for all Reporting API tables. The LearnerModulePerformancesIncremental (LMPI) table has its own pagination option, which fetches up to 5,000,000 records.
Client-side pagination
Section titled “Client-side pagination”Client-side pagination is available for all customers by default.
How client-side pagination works
Section titled “How client-side pagination works”The Reporting API supports client-side pagination with the skip and top options to fetch the data in batches when the data is large and cannot be fetched in a single go. Every time you run the script, the data is sorted on the primary key and the script skips the records that are already fetched. When no records have been fetched at the start, skip is 0, and the script runs until there are no records left to fetch.
Records inserted during an export may be absent until a later run. Treat each export as a changing dataset and reconcile successive exports by the table’s keys; do not assume a consistent snapshot across requests.
Best practices
Section titled “Best practices”- You do not need to apply sorting on top of pagination, because sorting is provided within the pagination implementation.
- When you use the
topoption to fetch top records, the value must not be greater than 300,000 for theCourseObjects,QuickUpdateObjects, andAssessmentObjectstables, and not greater than 100,000 for theLearnerModulePerformancestable.
How the skip value changes
Section titled “How the skip value changes”Consider the following three consecutive queries. The difference between them is the value of the skip option. When you use the skip option in a query, the full data is sorted after filtering on the primary key of the table, and records are skipped based on the skip value. In the first query there are no records fetched yet, so skip is 0. In the subsequent queries, the skip value increases as records are fetched.
First query, with top=100000 and skip=0:
https://admin.mindtickle.com/Odata.svc/CourseObjects?$format=csv&$filter=StartTime ge '2021-01-01 00:00:00' and StartTime le '2021-12-31 23:59:59'&$top=100000&$skip=0Second query, with top=100000 and skip=100000, because 100,000 records are already fetched:
https://admin.mindtickle.com/Odata.svc/CourseObjects?$format=csv&$filter=StartTime ge '2021-01-01 00:00:00' and StartTime le '2021-12-31 23:59:59'&$top=100000&$skip=100000Third query, with top=100000 and skip=200000, because 200,000 records are already fetched:
https://admin.mindtickle.com/Odata.svc/CourseObjects?$format=csv&$filter=StartTime ge '2021-01-01 00:00:00' and StartTime le '2021-12-31 23:59:59'&$top=100000&$skip=200000The script runs consecutive queries until the X-MTAPI-RecordCount token value in the header is 0. That is, the script execution completes when there are no more records to fetch.
After processing each query, a CSV file is created, with the file name in a predefined format. Each run uses a timestamp, including microseconds, in its file names. A page is first written to a temporary .csv.part file and then renamed to .csv after the write completes. A failed run leaves earlier completed pages in place and does not automatically resume; keep output from separate runs apart to avoid importing overlapping exports. The file name format is:
table_name + str(datetime.now().strftime("_%Y_%m_%d_%H_%M_%S_%f_")) + str(first_row_num) + '_to_' + str(last_row_num) + '.csv'Where table_name is CourseObjects, first_row_num is 1, last_row_num is 100000, and str(datetime.now().strftime("_%Y_%m_%d_%H_%M_%S_%f_")) is 2022_01_17_12_58_18_123456. For those values, the file name is CourseObjects_2022_01_17_12_58_18_123456_1_to_100000.csv.
Client-side pagination script
Section titled “Client-side pagination script”Use this standalone Python sample to export CSV pages. Install requests, set your Reporting credentials and regional base URL, and edit options for your query. Keep $format set to csv. The request timeout is 10 seconds to connect and 120 seconds without received data, not a total export deadline. Each page gets at most three attempts for connection failures, timeouts, and HTTP 429, 500, 502, 503, or 504 responses. Other failures stop the script. A completed page is saved before pagination advances; Finished Processing appears only after normal completion.
import timefrom datetime import datetimefrom pathlib import Pathfrom urllib.parse import urlencode
import requestsfrom requests.auth import HTTPBasicAuth
MAX_ATTEMPTS = 3RETRY_DELAY = 60REQUEST_TIMEOUT = (10, 120)
def get_query_url(base_url, options): return f"{base_url}?{urlencode({key: value for key, value in options.items() if value is not None})}"
def fetch_page(url): for attempt in range(1, MAX_ATTEMPTS + 1): try: with requests.get( url, auth=HTTPBasicAuth(username, password), timeout=REQUEST_TIMEOUT, allow_redirects=False, ) as response: response.raise_for_status() if response.status_code != 200: raise ValueError("Expected HTTP 200; check the configured Reporting URL") count = int(response.headers.get('X-MTAPI-RecordCount', '')) if count < 0: raise ValueError("X-MTAPI-RecordCount must be nonnegative") return response.content, count, response.headers.get('X-MTAPI-NextToken') except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as error: if isinstance(error, requests.HTTPError) and error.response.status_code not in { 429, 500, 502, 503, 504 }: raise if attempt == MAX_ATTEMPTS: raise print(f"Request failed; retrying this page in {RETRY_DELAY} seconds") time.sleep(RETRY_DELAY)
def save_csv_page(content, file_name): target = Path(file_name) temporary = target.with_suffix('.csv.part') try: temporary.write_bytes(content) temporary.replace(target) finally: temporary.unlink(missing_ok=True)
table_name = 'CourseObjects'base_url = f'https://admin.mindtickle.com/Odata.svc/{table_name}'username = 'username'password = 'password'options = { '$format': 'csv', '$top': 10000, '$select': None, '$filter': None, '$orderby': None,}
def run(): total_rows = 0 iteration = 0 export_time = datetime.now().strftime('_%Y_%m_%d_%H_%M_%S_%f_') while True: url = get_query_url(base_url, {**options, '$skip': total_rows}) content, count, _ = fetch_page(url) if count == 0: break last_row = total_rows + count file_name = f'{table_name}{export_time}{total_rows + 1}_to_{last_row}.csv' save_csv_page(content, file_name) total_rows = last_row iteration += 1 print(f'Total rows after iteration {iteration} = {total_rows}') print('Finished Processing')
if __name__ == '__main__': run()Pagination for the LMPI table
Section titled “Pagination for the LMPI table”The Reporting API supports pagination for the LearnerModulePerformancesIncremental (LMPI) table. With the pagination script you can pull up to 5,000,000 records.
- Pagination with the
$paginationoption is supported only for theLearnerModulePerformancesIncrementaltable. - If your API requests do not exceed 100,000 records, you do not need to implement pagination and can use the
LearnerModulePerformancesIncrementaltable normally. You must implement pagination if your API requests exceed 100,000 records. - With the pagination script you can pull up to 5,000,000 records across successive requests. Each generated CSV file can contain a maximum of 100,000 rows. For example, if there are 4,251,795 records in total, 43 CSV files are created: 42 files with 100,000 records each, and 1 file with the remaining 51,795 records.
The date filter must be within the last 30 days. The dated URLs below illustrate query syntax; replace their dates before use. Obtain the initial data dump before starting incremental extraction.
To use pagination, add the $pagination=true option to the OData query and apply the date filter. For example:
https://admin.mindtickle.com/Odata.svc/LearnerModulePerformancesIncremental?$format=csv&$filter=Date ge '2025-03-17'&$pagination=trueOnce you run the query, look at the value returned by next_token, which is resp.headers.get('X-MTAPI-NextToken'), to know whether there is more data to be fetched:
- If
next_tokenreturns a query URL, that is the value is notNone, there is more data to be fetched. Use the query URL returned bynext_tokento run another query. Run consecutive queries in the script until thenext_tokenvalue isNone. - If
next_tokenisNone, all data has been fetched.
The example here accepts next_token as a full query URL and rejects other formats. If your response uses a different format, confirm how to use it with Mindtickle Support before continuing. The continuation URL includes an Id filter based on the table’s primary columns, ModuleId, UserId, and SeriesId, and the LastUpdatedTime column, with LastUpdatedTime taking priority. This filter excludes already fetched records; it does not guarantee snapshot consistency while data changes.
Running a request with pagination
Section titled “Running a request with pagination”-
Run the following first query with the date filter and the
$pagination=trueoption. ReplaceSTART_DATEwith a date in YYYY-MM-DD format within the last 30 days.https://admin.mindtickle.com/Odata.svc/LearnerModulePerformancesIncremental?$format=csv&$filter=Date ge 'START_DATE'&$pagination=true -
The following is returned as output:
- A CSV file is created with the name in the
file_nameformat. - If
next_tokenreturns a query URL, that is the value is notNone, there is more data to be fetched. Use the query URL returned bynext_tokento run another query. The query URL contains an additionalIdfilter.
- A CSV file is created with the name in the
-
If there are more records to be fetched, that is
next_tokenis notNone, use the query URL returned bynext_tokenin step 2 to run another query. Keep its date filter unchanged. The following schematic example uses the sameSTART_DATEas step 1; use the actual returned URL rather than constructing theIdcursor yourself:https://admin.mindtickle.com/Odata.svc/LearnerModulePerformancesIncremental?$format=csv&$filter=Date ge 'START_DATE' and Id gt '1641851706001567027402678076815628'&$pagination=true -
Repeat step 3 until the
next_tokenvalue isNoneand there are no more records left to be fetched.
LMPI pagination script
Section titled “LMPI pagination script”This sample accepts full-URL continuation tokens. It stops if a continuation URL is repeated, malformed, or points outside the configured table, preventing credentials from being sent to another destination.
This standalone sample uses the same CSV output, request timeouts, retries, and failure handling described in Client-side pagination script. Set your credentials, regional base URL, and query options before running it. The sample starts 7 days before the current UTC date to stay within the 30-day window; this is an example range, not a recommended synchronization schedule.
import timefrom datetime import datetime, timedelta, timezonefrom pathlib import Pathfrom urllib.parse import urlencode, urlsplit
import requestsfrom requests.auth import HTTPBasicAuth
MAX_ATTEMPTS = 3RETRY_DELAY = 60REQUEST_TIMEOUT = (10, 120)
def get_query_url(base_url, options): return f"{base_url}?{urlencode({key: value for key, value in options.items() if value is not None})}"
def fetch_page(url): for attempt in range(1, MAX_ATTEMPTS + 1): try: with requests.get( url, auth=HTTPBasicAuth(username, password), timeout=REQUEST_TIMEOUT, allow_redirects=False, ) as response: response.raise_for_status() if response.status_code != 200: raise ValueError("Expected HTTP 200; check the configured Reporting URL") count = int(response.headers.get('X-MTAPI-RecordCount', '')) if count < 0: raise ValueError("X-MTAPI-RecordCount must be nonnegative") return response.content, count, response.headers.get('X-MTAPI-NextToken') except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as error: if isinstance(error, requests.HTTPError) and error.response.status_code not in { 429, 500, 502, 503, 504 }: raise if attempt == MAX_ATTEMPTS: raise print(f"Request failed; retrying this page in {RETRY_DELAY} seconds") time.sleep(RETRY_DELAY)
def save_csv_page(content, file_name): target = Path(file_name) temporary = target.with_suffix('.csv.part') try: temporary.write_bytes(content) temporary.replace(target) finally: temporary.unlink(missing_ok=True)
def get_next_url(token, current_url, seen_urls): if token is None: return None continuation = urlsplit(token) expected = urlsplit(base_url) if ( continuation.scheme != 'https' or continuation.netloc != expected.netloc or continuation.path != expected.path or not continuation.query or continuation.fragment ): raise ValueError('Expected a full continuation URL for the same Reporting table') if token == current_url or token in seen_urls: raise ValueError('The continuation URL repeated; pagination stopped') return token
table_name = 'LearnerModulePerformancesIncremental'base_url = f'https://admin.mindtickle.com/Odata.svc/{table_name}'username = 'username'password = 'password'start_date = (datetime.now(timezone.utc) - timedelta(days=7)).date().isoformat()options = { '$format': 'csv', '$filter': f"Date ge '{start_date}'", '$select': None, '$pagination': 'true',}
def run(): url = get_query_url(base_url, options) seen_urls = set() total_rows = 0 iteration = 0 export_time = datetime.now().strftime('_%Y_%m_%d_%H_%M_%S_%f_') while url is not None: content, count, token = fetch_page(url) next_url = get_next_url(token, url, seen_urls) if count: last_row = total_rows + count file_name = f'{table_name}{export_time}{total_rows + 1}_to_{last_row}.csv' save_csv_page(content, file_name) total_rows = last_row seen_urls.add(url) url = next_url iteration += 1 print(f'Total rows after iteration {iteration} = {total_rows}') print('Finished Processing')
if __name__ == '__main__': run()