Skip to main content

Handling 401 Unauthorized & Resolver Errors in the PropellerAds Reporting API

 Building automated ETL pipelines for AdTech platforms often involves wrestling with inconsistent API documentation. If you are integrating the PropellerAds v5 SSP (Publisher) API, you have likely encountered a specific, frustrating blockade.

You construct what looks like a valid Python request, but the server returns a hard 401 Unauthorized. Alternatively, when testing via the provided Swagger UI, you are met with opaque "Resolver error" messages that prevent you from inspecting the response schema.

This article dissects the root causes of these authentication failures and provides a production-grade Python solution to automate your statistics retrieval reliably.

The Anatomy of the 401 and Resolver Errors

Before jumping into the code, it is critical to understand why these requests fail. The PropellerAds API v5 is strict regarding header formation and payload structure, but the error messages are often generic.

1. The "Bearer" Token Trap

The most common cause of a 401 error in this context is an improperly formatted Authorization header. Unlike some APIs that accept the token as a query parameter or a raw header value, PropellerAds strictly implements the OAuth 2.0 Bearer Token usage standard (RFC 6750).

If your header looks like this, it will fail: {"Authorization": "12345_my_token"}

It must be prefixed with the schema type: {"Authorization": "Bearer 12345_my_token"}

2. Swagger "Resolver Error"

The "Resolver error" visible in the web documentation interface typically indicates a misalignment between the OpenAPI specification definition and the browser's ability to resolve references ($ref) in the schema.

Crucially, this is a documentation UI bug, not an API endpoint bug.

Developers often assume the endpoint is broken because Swagger won't load it. This is incorrect. The endpoint functions correctly if hit programmatically with the correct headers, even if the Swagger UI fails to render the interactive console.

3. Endpoint Structure Confusion

The v5 API splits functionality between Publishers (SSP) and Advertisers (DSP). A common 401 trigger is attempting to use a Publisher API token against an Advertiser endpoint (or vice versa).

  • Publisher Base: https://ssp-api.propellerads.com/v5/
  • Advertiser Base: https://ssp-api.propellerads.com/v5/adv/ (Requires different permissions)

Technical Implementation: The Python Fix

Below is a robust, object-oriented Python solution designed for extraction scripts. This implementation handles session pooling, proper header formatting, and granular error checking.

It utilizes the requests library for HTTP standard compliance and dataclasses for configuration management.

Prerequisites

Ensure you have the requests library installed:

pip install requests

The Production-Ready Client

import requests
import json
from dataclasses import dataclass
from typing import Dict, Any, Optional
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

@dataclass
class APIConfig:
    """Configuration holder for API credentials."""
    api_token: str
    base_url: str = "https://ssp-api.propellerads.com/v5/pub"
    timeout: int = 30

class PropellerAdsClient:
    """
    A robust client for the PropellerAds SSP v5 API.
    Handles authentication, session pooling, and error parsing.
    """

    def __init__(self, config: APIConfig):
        self.config = config
        self.session = self._init_session()

    def _init_session(self) -> requests.Session:
        """
        Initializes a requests Session with retry logic.
        This prevents transient network glitches from crashing the pipeline.
        """
        session = requests.Session()
        
        # Proper Authentication Header Construction
        session.headers.update({
            "Authorization": f"Bearer {self.config.api_token}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        })

        # Retry strategy: 3 retries on status 500, 502, 503, 504
        retries = Retry(
            total=3,
            backoff_factor=1,
            status_forcelist=[500, 502, 503, 504],
            allowed_methods=["POST", "GET"]
        )
        
        adapter = HTTPAdapter(max_retries=retries)
        session.mount("https://", adapter)
        
        return session

    def get_statistics(self, date_from: str, date_to: str, group_by: list[str]) -> Dict[str, Any]:
        """
        Retrieves publisher statistics.
        
        :param date_from: Start date in 'YYYY-MM-DD' format
        :param date_to: End date in 'YYYY-MM-DD' format
        :param group_by: List of grouping keys (e.g., ['zone_id', 'country'])
        """
        endpoint = f"{self.config.base_url}/statistics/publisher"
        
        payload = {
            "date_from": date_from,
            "date_to": date_to,
            "group_by": group_by
        }

        try:
            response = self.session.post(
                endpoint, 
                data=json.dumps(payload),
                timeout=self.config.timeout
            )
            
            # Raise HTTPError for 4xx/5xx responses
            response.raise_for_status()
            
            return response.json()

        except requests.exceptions.HTTPError as err:
            self._handle_http_error(err)
        except requests.exceptions.JSONDecodeError:
            raise ValueError("API returned non-JSON response. Possible 502 Bad Gateway or maintenance.")
        except Exception as e:
            raise SystemError(f"Unexpected connection error: {str(e)}")

    def _handle_http_error(self, error: requests.exceptions.HTTPError):
        """
        Parses specific API error messages for better debugging.
        """
        response = error.response
        status_code = response.status_code
        
        try:
            error_data = response.json()
            api_message = error_data.get("message", "No message provided")
        except:
            api_message = response.text

        if status_code == 401:
            raise PermissionError(
                f"401 Unauthorized. Check your Bearer token. Server message: {api_message}"
            )
        elif status_code == 422:
            raise ValueError(
                f"422 Validation Error. Check payload fields (dates/group_by). Server message: {api_message}"
            )
        else:
            raise error

# --- Usage Example ---

if __name__ == "__main__":
    # Replace with your actual API token from the dashboard
    MY_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." 
    
    config = APIConfig(api_token=MY_TOKEN)
    client = PropellerAdsClient(config)
    
    try:
        stats = client.get_statistics(
            date_from="2023-10-01",
            date_to="2023-10-02",
            group_by=["zone_id", "geo"]
        )
        
        print(f"Successfully retrieved {len(stats)} records.")
        # Example processing: print first record
        if stats:
            print(json.dumps(stats[0], indent=2))
            
    except PermissionError as e:
        print(f"Auth Failed: {e}")
    except ValueError as e:
        print(f"Data Error: {e}")

Deep Dive: Why This Solution Works

The Authorization Header

In _init_session, notice the line: "Authorization": f"Bearer {self.config.api_token}"

This manually constructs the header string. Many generic HTTP clients attempt to Base64 encode credentials (Basic Auth) or pass the token as a parameter. By explicitly formatting the string with the Bearer prefix (note the space), we satisfy the server's security filter immediately.

Payload Serialization

The get_statistics method uses json.dumps(payload) inside the POST request. While requests allows passing a dictionary to the json= parameter, explicit serialization helps debug exactly what is being sent over the wire if you need to log the payload before transmission.

Robust Error Parsing

Standard response.raise_for_status() calls are insufficient for API debugging. The _handle_http_error method inspects the JSON body of the error response. PropellerAds often returns specific validation details inside the 422 error body (e.g., "Invalid grouping parameter"). Generic error handling swallows this critical information.

Common Pitfalls and Edge Cases

Even with a working client, there are specific logical constraints in the PropellerAds reporting API that can cause unexpected behavior.

1. The 422 Unprocessable Entity

If you receive a 422 error after fixing your 401 error, you are likely grouping by incompatible dimensions.

  • Fix: Ensure your group_by array uses valid keys. Common keys are zone_idcountryos, and browser. Combining granular reporting (like sub_id) with broad date ranges can also trigger timeouts or validation errors due to result set size.

2. Timezone Discrepancies

The API generally operates in EST or UTC depending on your account settings.

  • Risk: Requesting data for "today" might return an empty list if the server time hasn't rolled over to the new day yet, or if it is ahead of your local time. Always rely on date_from and date_to parameters rather than assuming defaults.

3. Content-Type Enforcement

The server runs on Nginx with strict ingress rules. If you omit Content-Type: application/json, the API may try to parse your JSON payload as form-data, resulting in a 400 Bad Request or a 500 Internal Server Error. The _init_session method in the code above enforces this header globally for all requests made by the client.

Conclusion

The "Resolver error" in the PropellerAds documentation is a red herring. The underlying API is functional but demands strict adherence to the Bearer authentication scheme. By wrapping your requests in a structured Python class that enforces header formatting and retries, you can bypass the documentation bugs and establish a reliable data stream for your analytics dashboard.