> For the complete documentation index, see [llms.txt](https://docs.pullbay.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.pullbay.com/documentation/integrations/api-integration-guide.md).

# API Integration Guide

This guide covers integrating Pullbay's data APIs into backend applications and services. Learn authentication, error handling, rate limiting, and how to build robust integrations across Python, Node.js, PHP, and Ruby.

***

## Architecture: Server-Side Integration Only

{% hint style="danger" %}
**Critical Security Rule**: Never expose your Pullbay API key in client-side code.
{% endhint %}

### Why Server-Side Only?

API keys are secrets. Exposing them in client-side JavaScript, mobile apps, or public repositories allows attackers to:

* Consume your API quota and credits
* Impersonate your application
* Access your data
* Cause financial damage through runaway API usage

### Where to Store Your API Key

**Do:**

* Environment variables in your backend application
* Secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager)
* Encrypted configuration files (accessed only by your backend)
* Secure CI/CD pipeline secrets

**Don't:**

* Hardcoded in source files
* Client-side JavaScript
* Mobile app code
* Public repositories (GitHub, GitLab, etc.)
* Unencrypted configuration files

***

## Client-Side Access: Build a Backend Proxy

If your frontend needs to fetch Pullbay data, **never call Pullbay directly from the browser**. Instead, build a backend API endpoint that acts as a proxy:

```
Frontend → Your Backend Proxy → Pullbay API
```

### Proxy Pattern

```
GET /api/reviews?appId=389801252
    ↓
[Your Backend Server]
    ↓ (API Key added securely)
Pullbay API
    ↓
[Your Backend Server]
    ↓
Frontend (JSON response)
```

**Benefits:**

* API key never leaves your backend
* Control who can access review data
* Add authentication/authorization
* Rate limit per user
* Cache responses to reduce credits

**Example Backend Proxy (Node.js):**

```javascript
app.get('/api/reviews', authMiddleware, async (req, res) => {
  const { appId } = req.query;

  const response = await axios.get('https://api.pullbay.com/appstore/reviews', {
    params: { appId },
    headers: { 'Authorization': `Bearer ${process.env.PULLBAY_API_KEY}` }
  });

  res.json(response.data);
});
```

***

## Response Envelope

All Pullbay responses follow the same envelope. Understanding this structure is essential before implementing any client:

```json
{
  "requestId": "3b8dcb68-1f8c-4a7b-b9e7-63a7b9986f24",
  "status": 200,
  "message": "OK",
  "success": true,
  "data": [...],
  "pagination": {
    "page": null,
    "hasNextPage": true,
    "cursor": "eyJpZCI6InJldl8wNTAifQ==",
    "offset": null
  },
  "pricing": { "creditsCharged": 52 }
}
```

| Field                    | Description                                                                                      |
| ------------------------ | ------------------------------------------------------------------------------------------------ |
| `requestId`              | Unique request identifier — log this for every call. Also returned as the `X-Request-Id` header. |
| `status`                 | HTTP status code mirrored in the body                                                            |
| `success`                | `true` on success, `false` on error                                                              |
| `data`                   | Array of result objects                                                                          |
| `pagination`             | Present on paginated endpoints; fields are `null` when not applicable                            |
| `pricing.creditsCharged` | Credits consumed by this request                                                                 |

Error responses:

```json
{
  "requestId": "3b8dcb68-1f8c-4a7b-b9e7-63a7b9986f24",
  "status": 400,
  "message": "Validation failed",
  "success": false,
  "error": { "code": "VALIDATION_ERROR" }
}
```

***

## Centralized PullbayClient Class

Create a centralized client class for all Pullbay API calls. This ensures consistent authentication, error handling, and credit tracking across your application.

### Python Example

```python
import os
import requests
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter

class PullbayClient:
    """Centralized client for Pullbay API calls."""

    BASE_URL = "https://api.pullbay.com"

    def __init__(self, api_key=None):
        self.api_key = api_key or os.getenv("PULLBAY_API_KEY")
        if not self.api_key:
            raise ValueError("PULLBAY_API_KEY environment variable not set")

        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Bearer {self.api_key}",
        })

        retry_strategy = Retry(
            total=3,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["GET"],
            backoff_factor=1,
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        self.session.mount("https://", adapter)

    def get_appstore_reviews(self, app_id, country="us", sort="recent", max_items=None):
        """
        Fetch App Store reviews for an app.

        Args:
            app_id (str): App Store numeric ID matching ^\d{8,12}$
            country (str): 2-letter country code (default: 'us')
            sort (str): 'recent' or 'helpful' (default: 'recent')
            max_items (int): Bulk pull limit 1–500; omit to page manually with cursor

        Returns:
            dict: Full Pullbay response envelope
        """
        params = {"appId": app_id, "country": country, "sort": sort}
        if max_items is not None:
            params["maxItems"] = max_items

        response = self.session.get(
            f"{self.BASE_URL}/appstore/reviews",
            params=params,
            timeout=120,
        )
        response.raise_for_status()
        return response.json()


# Usage
client = PullbayClient()
result = client.get_appstore_reviews(app_id="389801252", country="us", max_items=50)
print(f"Fetched {len(result['data'])} reviews — {result['pricing']['creditsCharged']} credits")
```

***

## Implementation Checklist

Before deploying your integration, ensure you've implemented:

* [ ] **Authentication**: API key stored in environment variables or secrets manager
* [ ] **Error Handling**: Catch and handle 4xx and 5xx errors appropriately
* [ ] **Rate Limiting**: Respect rate limits; implement exponential backoff on 429
* [ ] **Credit Tracking**: Log `pricing.creditsCharged` from every response
* [ ] **Request Logging**: Log `requestId` for every API call for debugging
* [ ] **Retries**: Retry on 429 and 5xx with backoff; never retry 4xx except 429
* [ ] **Timeout**: Set 30s timeout for single-page requests; 120s when using `maxItems`
* [ ] **Monitoring**: Set up alerts for API failures and credit burn rate
* [ ] **Testing**: Mock the Pullbay response envelope in unit tests; never call the real API in tests

***

## Language-Specific Examples

{% tabs %}
{% tab title="Python" %}

### Python (with Requests and Retries)

Complete production-ready Python integration:

```python
import os
import logging
import requests
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class PullbayClient:
    """Production-grade Pullbay API client."""

    BASE_URL = "https://api.pullbay.com"

    def __init__(self, api_key=None, max_retries=3):
        self.api_key = api_key or os.getenv("PULLBAY_API_KEY")
        if not self.api_key:
            raise ValueError("PULLBAY_API_KEY not configured")

        self.credits_used = 0
        self.session = self._build_session(max_retries)

    def _build_session(self, max_retries):
        session = requests.Session()
        session.headers.update({"Authorization": f"Bearer {self.api_key}"})

        retry = Retry(
            total=max_retries,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["GET"],
            backoff_factor=1,
            raise_on_status=False,
        )
        adapter = HTTPAdapter(max_retries=retry)
        session.mount("https://", adapter)
        return session

    def get_appstore_reviews(self, app_id, country="us", sort="recent", max_items=None):
        """
        Fetch App Store reviews.

        Args:
            app_id (str): App Store ID — digits only, 8–12 chars
            country (str): 2-letter country code (default: 'us')
            sort (str): 'recent' or 'helpful' (default: 'recent')
            max_items (int): 1–500. Omit to paginate manually with cursor.

        Returns:
            dict: Pullbay response envelope
        """
        params = {"appId": app_id, "country": country, "sort": sort}
        if max_items is not None:
            params["maxItems"] = max_items

        try:
            logger.info("Fetching App Store reviews for app %s", app_id)

            response = self.session.get(
                f"{self.BASE_URL}/appstore/reviews",
                params=params,
                timeout=120,
            )

            if response.status_code == 401:
                raise ValueError("Invalid API key")
            if response.status_code == 402:
                raise RuntimeError("Insufficient credits — add credits in your dashboard")
            if response.status_code == 429:
                raise RuntimeError("Rate limited — back off and retry")
            if response.status_code >= 500:
                raise RuntimeError(f"Pullbay server error: {response.status_code}")

            response.raise_for_status()

            body = response.json()
            credits = body["pricing"]["creditsCharged"]
            self.credits_used += credits
            logger.info(
                "Fetched %d reviews (requestId=%s, creditsCharged=%d)",
                len(body["data"]), body["requestId"], credits,
            )
            return body

        except requests.exceptions.Timeout:
            logger.error("Request timed out")
            raise
        except requests.exceptions.ConnectionError:
            logger.error("Connection error — unable to reach Pullbay API")
            raise

    def paginate_appstore_reviews(self, app_id, country="us", sort="recent", max_results=None):
        """
        Generator that yields individual reviews across all cursor pages.

        Args:
            app_id (str): App Store ID
            country (str): 2-letter country code
            sort (str): 'recent' or 'helpful'
            max_results (int): Stop after this many results (None = all)
        """
        cursor = None
        fetched = 0

        while True:
            params = {"appId": app_id, "country": country, "sort": sort}
            if cursor:
                params["cursor"] = cursor

            response = self.session.get(
                f"{self.BASE_URL}/appstore/reviews",
                params=params,
                timeout=30,
            )
            response.raise_for_status()
            body = response.json()

            for item in body["data"]:
                yield item
                fetched += 1
                if max_results and fetched >= max_results:
                    return

            pagination = body.get("pagination", {})
            if not pagination.get("hasNextPage"):
                break
            cursor = pagination.get("cursor")
            if not cursor:
                break


# Usage
if __name__ == "__main__":
    client = PullbayClient()

    # Managed: fetch up to 500 reviews in one call
    result = client.get_appstore_reviews(
        app_id="389801252",
        country="us",
        sort="recent",
        max_items=100,
    )
    print(f"Total: {len(result['data'])}, Credits: {result['pricing']['creditsCharged']}")

    # Standard: iterate one page at a time
    for review in client.paginate_appstore_reviews("389801252", max_results=50):
        print(f"{review.get('userName', '—')}: {review.get('score', '—')}⭐")
```

{% endtab %}

{% tab title="Node.js" %}

### Node.js (with Axios)

```javascript
const axios = require('axios');

class PullbayClient {
  constructor(apiKey = null) {
    this.apiKey = apiKey || process.env.PULLBAY_API_KEY;
    if (!this.apiKey) {
      throw new Error('PULLBAY_API_KEY environment variable not set');
    }

    this.baseURL = 'https://api.pullbay.com';
    this.creditsUsed = 0;

    this.client = axios.create({
      baseURL: this.baseURL,
      timeout: 120000,
      headers: { 'Authorization': `Bearer ${this.apiKey}` },
    });

    this.client.interceptors.response.use(null, this._retryInterceptor.bind(this));
  }

  async getAppstoreReviews(appId, options = {}) {
    const params = {
      appId,
      country: options.country || 'us',
      sort: options.sort || 'recent',
    };

    if (options.maxItems) {
      params.maxItems = options.maxItems;
    }

    try {
      const response = await this.client.get('/appstore/reviews', { params });
      const body = response.data;

      this.creditsUsed += body.pricing.creditsCharged;
      console.log(
        `Fetched ${body.data.length} reviews — requestId: ${body.requestId}, credits: ${body.pricing.creditsCharged}`
      );

      return body;
    } catch (error) {
      const status = error.response?.status;
      if (status === 401) throw new Error('Invalid API key');
      if (status === 402) throw new Error('Insufficient credits');
      if (status === 429) throw new Error('Rate limited — retry after backoff');
      if (status >= 500) throw new Error(`Pullbay server error: ${status}`);
      throw error;
    }
  }

  async *paginateAppstoreReviews(appId, options = {}) {
    let cursor = null;
    let fetched = 0;
    const maxResults = options.maxResults || Infinity;

    while (true) {
      const params = {
        appId,
        country: options.country || 'us',
        sort: options.sort || 'recent',
      };
      if (cursor) params.cursor = cursor;

      const response = await this.client.get('/appstore/reviews', { params });
      const body = response.data;

      for (const item of body.data) {
        yield item;
        fetched++;
        if (fetched >= maxResults) return;
      }

      if (!body.pagination?.hasNextPage) break;
      cursor = body.pagination?.cursor;
      if (!cursor) break;
    }
  }

  async _retryInterceptor(error) {
    const config = error.config;
    if (!config) return Promise.reject(error);

    config.retryCount = config.retryCount || 0;

    const isRetryable =
      (error.response?.status >= 500) ||
      ['ECONNABORTED', 'ECONNRESET'].includes(error.code);

    if (isRetryable && config.retryCount < 3) {
      config.retryCount++;
      const delay = Math.pow(2, config.retryCount - 1) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
      return this.client(config);
    }

    return Promise.reject(error);
  }
}

// Usage
(async () => {
  const client = new PullbayClient();

  // Managed: fetch up to 100 reviews in one call
  const result = await client.getAppstoreReviews('389801252', {
    country: 'us',
    sort: 'recent',
    maxItems: 100,
  });
  console.log(`Total: ${result.data.length}, Credits: ${result.pricing.creditsCharged}`);

  // Standard: iterate one page at a time
  for await (const review of client.paginateAppstoreReviews('389801252', { maxResults: 50 })) {
    console.log(`${review.userName}: ${review.score}⭐`);
  }
})();

module.exports = PullbayClient;
```

{% endtab %}

{% tab title="PHP" %}

### PHP (with Guzzle)

```php
<?php

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\ResponseInterface;

class PullbayClient {
    private Client $httpClient;
    private string $apiKey;
    private int $creditsUsed = 0;

    public function __construct(?string $apiKey = null) {
        $this->apiKey = $apiKey ?: (string) getenv('PULLBAY_API_KEY');

        if (!$this->apiKey) {
            throw new \Exception('PULLBAY_API_KEY environment variable not set');
        }

        $stack = HandlerStack::create();
        $stack->push(Middleware::retry($this->retryDecider(), $this->retryDelay()));

        $this->httpClient = new Client([
            'base_uri' => 'https://api.pullbay.com/',
            'handler'  => $stack,
            'timeout'  => 120,
            'headers'  => [
                'Authorization' => 'Bearer ' . $this->apiKey,
            ],
        ]);
    }

    public function getAppstoreReviews(string $appId, array $options = []): array {
        $query = [
            'appId'   => $appId,
            'country' => $options['country'] ?? 'us',
            'sort'    => $options['sort'] ?? 'recent',
        ];

        if (isset($options['maxItems'])) {
            $query['maxItems'] = $options['maxItems'];
        }

        $response = $this->httpClient->get('appstore/reviews', ['query' => $query]);
        $body = json_decode((string) $response->getBody(), true);

        $this->creditsUsed += $body['pricing']['creditsCharged'];
        echo "Fetched " . count($body['data']) . " reviews"
            . " (requestId: {$body['requestId']},"
            . " credits: {$body['pricing']['creditsCharged']})\n";

        return $body;
    }

    private function retryDecider(): callable {
        return function (int $retries, $request, ?ResponseInterface $response = null, $error = null): bool {
            if ($retries >= 3) return false;
            if ($response && $response->getStatusCode() >= 500) return true;
            if ($error !== null) return true;
            return false;
        };
    }

    private function retryDelay(): callable {
        return fn(int $retries): int => 1000 * (int) pow(2, $retries);
    }

    public function getCreditsUsed(): int {
        return $this->creditsUsed;
    }
}

// Usage
$client = new PullbayClient();

$result = $client->getAppstoreReviews('389801252', [
    'country'  => 'us',
    'sort'     => 'recent',
    'maxItems' => 100,
]);

echo "Total: " . count($result['data']) . "\n";
echo "Credits: " . $result['pricing']['creditsCharged'] . "\n";

foreach ($result['data'] as $review) {
    echo "{$review['userName']} ({$review['score']}⭐): "
        . substr($review['text'] ?? '', 0, 80) . "\n";
}
```

{% endtab %}

{% tab title="Ruby" %}

### Ruby (with Net::HTTP)

```ruby
require 'net/http'
require 'json'
require 'uri'

class PullbayClient
  BASE_URL = 'https://api.pullbay.com'.freeze

  def initialize(api_key = nil)
    @api_key = api_key || ENV['PULLBAY_API_KEY']
    raise 'PULLBAY_API_KEY not set' unless @api_key

    @credits_used = 0
    @max_retries = 3
  end

  def get_appstore_reviews(app_id, country: 'us', sort: 'recent', max_items: nil)
    params = { appId: app_id, country: country, sort: sort }
    params[:maxItems] = max_items if max_items

    body = request(path: '/appstore/reviews', params: params)

    @credits_used += body['pricing']['creditsCharged']
    puts "Fetched #{body['data'].length} reviews " \
         "(requestId: #{body['requestId']}, credits: #{body['pricing']['creditsCharged']})"

    body
  end

  def paginate_appstore_reviews(app_id, country: 'us', sort: 'recent', max_results: nil)
    return enum_for(:paginate_appstore_reviews, app_id, country: country, sort: sort, max_results: max_results) unless block_given?

    cursor  = nil
    fetched = 0

    loop do
      params = { appId: app_id, country: country, sort: sort }
      params[:cursor] = cursor if cursor

      body       = request(path: '/appstore/reviews', params: params)
      pagination = body['pagination'] || {}

      body['data'].each do |item|
        yield item
        fetched += 1
        return if max_results && fetched >= max_results
      end

      break unless pagination['hasNextPage']
      cursor = pagination['cursor']
      break unless cursor
    end
  end

  private

  def request(path:, params: {})
    retries = 0

    begin
      uri       = URI("#{BASE_URL}#{path}")
      uri.query = URI.encode_www_form(params)

      http          = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl  = true
      http.read_timeout = 120

      req = Net::HTTP::Get.new(uri)
      req['Authorization'] = "Bearer #{@api_key}"

      response = http.request(req)

      case response.code.to_i
      when 200..299
        JSON.parse(response.body)
      when 401
        raise 'Invalid API key'
      when 402
        raise 'Insufficient credits'
      when 429
        raise 'Rate limited'
      when 500..599
        raise "Server error: #{response.code}"
      else
        raise "HTTP #{response.code}"
      end

    rescue RuntimeError => e
      retries += 1
      if retries <= @max_retries && e.message.start_with?('Server error', 'Rate limited')
        sleep(2**retries)
        retry
      end
      raise
    end
  end
end

# Usage
client = PullbayClient.new

result = client.get_appstore_reviews('389801252', country: 'us', sort: 'recent', max_items: 100)
puts "Total: #{result['data'].length}, Credits: #{result['pricing']['creditsCharged']}"

client.paginate_appstore_reviews('389801252', max_results: 50) do |review|
  puts "#{review['userName']}: #{review['score']}⭐"
end
```

{% endtab %}
{% endtabs %}

***

## Testing and Mocking

### Mocking Responses in Unit Tests

Never make real API calls in unit tests. Mock the Pullbay response envelope instead.

**Python mock example:**

```python
import unittest
from unittest.mock import patch, MagicMock
from my_app import PullbayClient

class TestPullbayIntegration(unittest.TestCase):

    @patch.object(PullbayClient, 'session')
    def test_get_appstore_reviews(self, mock_session):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            'requestId': '3b8dcb68-1f8c-4a7b-b9e7-63a7b9986f24',
            'status': 200,
            'message': 'OK',
            'success': True,
            'data': [
                {
                    'id': 'rev_001',
                    'userName': 'test_user',
                    'score': 5,
                    'title': 'Great app!',
                    'text': 'Loved this app',
                }
            ],
            'pagination': {
                'page': None,
                'hasNextPage': False,
                'cursor': None,
                'offset': None,
            },
            'pricing': {'creditsCharged': 2},
        }
        mock_session.get.return_value = mock_response

        client = PullbayClient(api_key='test_key')
        result = client.get_appstore_reviews(app_id='389801252')

        self.assertEqual(len(result['data']), 1)
        self.assertEqual(result['data'][0]['userName'], 'test_user')
        self.assertEqual(result['pricing']['creditsCharged'], 2)
        self.assertIn('requestId', result)

if __name__ == '__main__':
    unittest.main()
```

***

## Security Considerations

### Environment Variables vs Secrets Manager

**For development** — use `.env` files:

```bash
PULLBAY_API_KEY=your_api_key_here
```

```python
import os
api_key = os.getenv('PULLBAY_API_KEY')
```

**For production** — use a secrets manager:

```python
import boto3

def get_api_key():
    client = boto3.client('secretsmanager', region_name='us-east-1')
    response = client.get_secret_value(SecretId='pullbay-api-key')
    return response['SecretString']
```

### API Key Rotation

1. Generate a new API key in the [Pullbay dashboard](https://dashboard.pullbay.com/dashboard)
2. Update your secrets manager with the new key
3. Redeploy or reload configuration
4. Verify the new key works
5. Deactivate the old key

***

## Frequently Asked Questions

<details>

<summary>Should I cache Pullbay responses?</summary>

Yes. Caching reduces credits and improves latency. Cache the full response body keyed by endpoint + parameters. Use a TTL appropriate for data freshness (e.g. 1 hour for reviews, 24 hours for static app metadata).

```python
from datetime import datetime, timedelta

class CachedPullbayClient(PullbayClient):
    def __init__(self, *args, cache_ttl=3600, **kwargs):
        super().__init__(*args, **kwargs)
        self._cache = {}
        self._cache_ttl = cache_ttl

    def get_appstore_reviews(self, app_id, **kwargs):
        key = f"{app_id}:{kwargs}"
        entry = self._cache.get(key)
        if entry and datetime.now() < entry['expires']:
            return entry['data']

        data = super().get_appstore_reviews(app_id, **kwargs)
        self._cache[key] = {
            'data': data,
            'expires': datetime.now() + timedelta(seconds=self._cache_ttl),
        }
        return data
```

</details>

<details>

<summary>How do I handle API key rotation without downtime?</summary>

Store the key in a secrets manager rather than in code. When you rotate:

1. Update the secret in the secrets manager
2. The application reads the new key on the next request (or on restart, depending on your setup)
3. No code changes or redeployment needed

</details>

<details>

<summary>Can I call Pullbay from a browser?</summary>

No. Never expose your API key in client-side code. Build a backend proxy endpoint that adds the key server-side (see the Proxy Pattern section above).

</details>

<details>

<summary>What is the difference between <code>maxItems</code> and using <code>cursor</code>?</summary>

* `maxItems` — Pullbay paginates internally and returns all items in a single response (up to the endpoint maximum). Simpler but takes longer for large datasets.
* `cursor` — You paginate one page at a time, processing results incrementally. More control; you can stop early to save credits.

They are mutually exclusive — you cannot use both on the same request.

</details>

<details>

<summary>How do I monitor credit usage?</summary>

Log `pricing.creditsCharged` from every response. Accumulate totals in your application and set up alerts when burn rate exceeds a threshold. The Pullbay dashboard also shows usage analytics.

</details>

<details>

<summary>What's the best way to handle rate limiting?</summary>

Respond to `429` responses by reading the `X-RateLimit-Reset` header and waiting until that timestamp before retrying. The Python, Node.js, PHP, and Ruby examples above all implement exponential backoff as a fallback.

</details>
