5. How to interface with the NRAO ODS API

This section describes how one may authenticate via token to access the NRAO ODS API server for some of the common use cases:

  • Querying ODS telescope schedule data from NRAO (Primary, see Getting ODS Data)

  • [In-development (as of Mar 2026)] Pushing Satellite Avoidance Logs to NRAO (for certain satellite operators only, see Uploading Satellite Avoidance Logs)

  • Pushing high-cadence Two-line Elements to NRAO (for certain satellite operators only, see Uploading TLE Data)

Example Python scripts for these use cases are provided below in Example Python Script.

5.1. Authentication

Most requests to the NRAO ODS API must be authenticated. This API supports token-based authentication.

  1. Contact an NRAO ODS Admin to generate a client for you with appropriate scopes. Coordinate the cadence at which your token will expire with NRAO.

  1. Post your client id and client secret as form data to the NRAO ODS API to get your authentication token.

curl -X 'POST' \
  'https://ods.nrao.edu/token/' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials&client_id=<client_id>&client_secret=<client_secret>'

Response 200 OK

{
  "token": "EXAMPLE_TOKEN"
}
  1. Include the token in the Authorization header to authenticate subsequent NRAO ODS API requests.

curl -X 'GET' \
  'https://ods.nrao.edu/ods_data/' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer <EXAMPLE_TOKEN>'

4. When your token expires, you will need to repeat Step 2 using the same client_id and client_secret to get a new token. Your client_id and client_secret do not change; you’ll only need to refresh the token itself.

  • Option 1: Proactive refresh. Keep track of when you first requested your token and refresh it before it expires.

  • Option 2: Reactive refresh (demonstrated in Example Python Script below). If you get a 403 (Forbidden) upon trying to perform an action that typically works for you, request a new token from /token and retry your request with the new token.

5.2. Getting ODS Data

These are the instructions to get ODS records from the NRAO ODS API.

1. Include your token in the Authorization header to GET /ods_data. This returns a list of mitigation requests that are in progress or starting in the next thirty minutes.

This endpoint is not paginated.

curl -X 'GET' \
  'https://ods.nrao.edu/ods_data/' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer <EXAMPLE_TOKEN>'

5.3. Uploading Satellite Avoidance Logs

These are the instructions to upload satellite avoidance log files to the NRAO ODS API. These logs describe various mitigating actions taken by satellite operators to avoid interference with telescopes.

5.3.1. Upload Instructions

Send your satellite avoidance log file to the NRAO ODS API using the multipart/form-data content type. You’ll need to include:

  • Your authentication token in the Authorization header

  • Your log file attached under the field name file

curl -X 'POST' \
  'https://ods.nrao.edu/sat_avoidance_logs' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer <EXAMPLE_TOKEN>' \
  -H 'Content-Type: multipart/form-data' \
  -F 'file=@<facility_start_date_end_date>.csv;type=text/csv'

Response 200 OK

{
  "ingested_records": "<number of records ingested>"
}

5.3.2. Satellite avoidance log format

The following CSV header row is expected:

utc_time,freq_lower_mhz,freq_upper_mhz,avoidance_type,angular_threshold,sat_id
  • utc_time - (UTC datetime without timezone, format: YYYY-MM-DD HH:MM:SS), e.g., 2025-08-30 00:05:57

  • freq_lower_mhz - (float) in MHz

  • freq_upper_mhz - (float) in MHz

  • avoidance_type - (str), one of [outer, inner]

  • angular_threshold - (Optional, float), in degrees (omit column altogether if not available)

  • sat_id - (int)

5.3.3. File name

The CSV file name must start with an agreed-upon facility abbreviation to describe where the avoidance action was taken. The suggested filename format is <FACILITY>_Logs_<utc_date_start>_to_<utc_date_end>.csv,

where:

  • <FACILITY> can be:
    • VLA (upper case)

    • GBT (upper case)

    • vlba_<STATION> (lower case vlba) with upper case station name

      • where <STATION> = {SC, HN, NL, FD, LA, PT, KP, OV, BR, MK}.

  • <utc_date_start> and <utc_date_end> are in the format of YYYY-MM-DD

5.3.4. Recommendations

  • Record the timestamps in a small time interval (at most every 10-15 seconds) during the entire avoidance event. If it’s less than one second, a single record entry in the logs will be sufficient.

  • Keep file sizes reasonable. File size is capped at 750 kB. Much smaller is preferred, e.g. less than 5000 records.

  • Use the ODS API responsibly. If uploading multiple files, please pause for at least ten seconds between each upload.

  • Use unique filenames for uploads to aid in troubleshooting

  • If your request fails with a validation error, address, then reupload the entire file. We ignore records we’ve seen before but this will help make sure the full file is ingested.

5.4. Uploading TLE Data

These are the instructions to upload TLE files to the NRAO ODS API.

Send your TLE .txt file to the NRAO ODS API using the multipart/form-data content type. You’ll need to include:

  • Your authentication token in the Authorization header

  • Your TLE file (a .txt file with TLE records in 3LE format) attached under the field name tle_file

TLE records must be formatted appropriately and a single TLE file must be less than 50 kB. Example:

ISS (ZARYA)
1 25544U 98067A   26034.04958920  .00010142  00000+0  19604-3 0  9992
2 25544  51.6308 243.5964 0011016  59.0626 301.1443 15.48354905550960
ISS (NAUKA)
1 49044U 21066A   26034.04958920  .00010142  00000+0  19604-3 0  9998
2 49044  51.6308 243.5964 0011016  59.0626 301.1443 15.48354905230449
curl -X 'POST' \
  'https://ods.nrao.edu/tle_data' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer <EXAMPLE_TOKEN>' \
  -H 'Content-Type: multipart/form-data' \
  -F 'tle_file=@<EXAMPLE_TEXT_FILE_NAME>.txt;type=text/plain'

Response 201 CREATED

{
  "ingested_records": "<number of TLE records sent>"
}

5.5. Example Python Script

An example Python script for reference only.
"""
Example ODS API Client

Usage, to get ODS Data:
python example_ods_api_client.py --get_ods_data

To upload a TLE file:
python example_ods_api_client.py --tle_file_path <path to TLE file>

To upload a directory of satellite avoidance logs:
python example_ods_api_client.py --tba_dir_path <log directory>

"""

# --------------------------------------------------------------------------------------------------
# NOTE: For demo purposes, loads client credentials from an .env file and persists an
# access token back to the .env file to be used for subsequent requests.
#
# For production, use a secure credential store like your OS keychain or secrets manager instead.
#
# Use the ODS API responsibly. Avoid unnecessary or repeated requests, rotate tokens when needed
# and respect rate limits.
# --------------------------------------------------------------------------------------------------
import argparse
import os
import time
from io import BufferedReader
from typing import Callable, Dict, Optional

import requests
from dotenv import load_dotenv, set_key
from loguru import logger

API_BASE = "https://ods.nrao.edu"
TOKEN_URL = f"{API_BASE}/token"
ODS_DATA_URL = f"{API_BASE}/ods_data"
SAT_AVOIDANCE_LOG_URL = f"{API_BASE}/sat_avoidance_logs"
TLE_DATA_URL = f"{API_BASE}/tle_data"

MAX_REQUEST_RETRIES = 3
RETRY_STATUS_CODES = [403, 408, 429, 500, 502, 503, 504]

# Load credentials from env file
ENV_FILE = ".ods.env"
load_dotenv(dotenv_path=ENV_FILE, verbose=True)


class ODSAPIClient:
    """Example Python ODS API Client for calling the NRAO ODS API"""

    def __init__(self, client_id: str, client_secret: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self.token = os.environ.get("ODS_ACCESS_TOKEN")

    def refresh_token(self) -> None:
        """Fetch a new access token from the API and persist to be used for
        all subsequent requests. Only re-generate tokens as necessary, not per-request.

        In production, use a secure storage mechanism for client credentials.
        """
        resp: requests.Response = requests.post(
            TOKEN_URL,
            data={
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            },
            timeout=5,
        )

        if resp.status_code == 200:
            new_token = resp.json()["token"]
            set_key(ENV_FILE, "ODS_ACCESS_TOKEN", new_token)
            self.token = new_token
        else:
            logger.info("Failed to refresh token")

        resp.raise_for_status()

    def ods_request_with_retry(
        self,
        method: Callable,
        url: str,
        files: Optional[Dict[str, BufferedReader]] = None,
    ):
        """
        Make requests to the ODS API with exponential backoff

        Reuse the existing token and rotate once the ODS API indicates it is no longer valid.
        """

        retries: int = 0
        response: Optional[requests.Response] = None

        while retries < MAX_REQUEST_RETRIES:
            if not self.token:
                self.refresh_token()

            response = method(
                url,
                headers={"Authorization": f"Bearer {self.token}"},
                files=files,
                timeout=5,
            )
            if response.status_code in RETRY_STATUS_CODES:
                if response.status_code == 403:
                    # Reactively refresh token if we get a 403
                    logger.info("Refreshing token")
                    self.refresh_token()
                delay: float = backoff_delay(2, retries)
                logger.info("Retrying in {} seconds...", delay)
                time.sleep(delay)
                retries += 1
            else:
                response.raise_for_status()
                return response

        if response is not None:
            response.raise_for_status()
        return response

    def get_ods_data(self) -> None:
        """Example request for getting ODS Data

        Client with appropriate scope required.
        """
        logger.info("Making request to get ODS Data")
        api_response = self.ods_request_with_retry(requests.get, ODS_DATA_URL)
        logger.info("Retrieved ODS data: ")
        logger.info(api_response.json()["ods_data"])

    def post_satellite_avoidance_logs(self, filepath: str) -> None:
        """Example function for uploading a directory of satellite avoidance logs to ODS

        Client with appropriate scope required.
        """
        if not os.path.isdir(filepath):
            raise Exception(f"Expecting directory: {filepath}")

        with os.scandir(filepath) as entries:
            entries = [
                entry
                for entry in entries
                if entry.name.endswith(".csv") or entry.name.endswith(".txt")
            ]
            sorted_entries = sorted(entries, key=lambda entry: entry.name)
            logger.info("Preparing to upload {} satellite avoidance logs...", len(entries))
            for entry in sorted_entries:
                with open(entry, "rb") as f:
                    files = {"file": f}
                    api_response = self.ods_request_with_retry(
                        requests.post, SAT_AVOIDANCE_LOG_URL, files
                    )
                    logger.info(
                        "Uploaded {} to ODS: {}", entry.name, api_response.json()
                    )
                    logger.info("Sleeping for ten seconds")
                    time.sleep(10)

    def post_tle_file(self, filepath: str) -> None:
        """Example function for uploading a TLE file to ODS

        Client with appropriate scope required.
        """
        logger.info("Uploading {} to ODS".format(filepath))
        with open(filepath, "rb") as f:
            files = {"tle_file": f}
            api_response = self.ods_request_with_retry(
                requests.post, TLE_DATA_URL, files
            )
            logger.info("Uploaded TLE file to ODS: {}", api_response.json())

def backoff_delay(backoff_factor: int, attempts: int) -> float:
    """Add exponential backoff delay"""
    return backoff_factor * (2 ** (attempts - 1))


def main(
    get_ods_data: Optional[bool] = False,
    tba_dir_path: Optional[str] = None,
    tle_file_path: Optional[str] = None,
) -> None:
    """
    Make requests to the ODS API to do things like getting ODS Data, uploading satellite avoidance logs,
    or uploading TLE files.
    """
    # Contact an NRAO ODS Admin to generate client credentials
    # with applicable permissions. Store securely.
    ods_client_id = os.getenv("ODS_CLIENT_ID")
    ods_client_secret = os.getenv("ODS_CLIENT_SECRET")

    api_client = ODSAPIClient(ods_client_id, ods_client_secret)

    if get_ods_data:
        api_client.get_ods_data()

    if tba_dir_path:
        api_client.post_satellite_avoidance_logs(tba_dir_path)

    if tle_file_path:
        api_client.post_tle_file(tle_file_path)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="A simple Python script to demonstrate talking to the ODS API."
    )

    parser.add_argument(
        "--get_ods_data", action="store_true", help="Flag to get ODS Data"
    )

    parser.add_argument(
        "--tba_dir_path",
        type=str,
        help="Optional path for a file of satellite avoidance logs to be uploaded to ODS",
    )

    parser.add_argument(
        "--tle_file_path",
        type=str,
        help="Optional path to a TLE file to be uploaded to ODS",
    )
    args = parser.parse_args()
    main(args.get_ods_data, args.tle_file_path, args.tba_dir_path)