> ## Documentation Index
> Fetch the complete documentation index at: https://bundleup.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Python

## Installation

Install the SDK using pip:

```bash theme={null}
pip install bundleup-sdk
```

**Using Poetry:**

```bash theme={null}
poetry add bundleup-sdk
```

**Using pipenv:**

```bash theme={null}
pipenv install bundleup-sdk
```

## Requirements

* **Python**: 3.8 or higher
* **requests**: >=2.25.0 (automatically installed)
* **typing-extensions**: >=4.0.0 (automatically installed)

### Python Compatibility

The BundleUp SDK is tested and supported on:

* Python 3.8
* Python 3.9
* Python 3.10
* Python 3.11
* Python 3.12

## Features

* 🚀 **Pythonic API** - Follows Python best practices and PEP 8
* 📦 **Easy Integration** - Simple, intuitive API design
* ⚡ **Async Support** - Built on requests with async capabilities
* 🔌 **100+ Integrations** - Connect to Slack, GitHub, Jira, Linear, and many more
* 🎯 **Unified API** - Consistent interface across all integrations via the Unified API
* 🔑 **Proxy API** - Direct access to underlying integration APIs
* 🤖 **MCP** - Hand a connection to any MCP client, or to OpenAI and Anthropic's hosted MCP
* 🪶 **Lightweight** - Minimal dependencies
* 🛡️ **Error Handling** - Comprehensive error messages and validation
* 📚 **Well Documented** - Extensive documentation and examples
* 🔍 **Type Hints** - Full type annotations for better IDE support
* 🧪 **Tested** - Comprehensive test suite with pytest

## Quick Start

Get started with BundleUp in just a few lines of code:

```python theme={null}
from bundleup import BundleUp
import os

# Initialize the client
client = BundleUp(os.environ['BUNDLEUP_API_KEY'])

# List all active connections
connections = client.connection.list()
print(f"You have {len(connections)} active connections")

# Use the Proxy API to make requests to integrated services
proxy = client.proxy('conn_123')
response = proxy.get('/api/users')
users = response.json()
print(f"Users: {users}")

# Use the Unified API for standardized data across integrations
unify = client.unify('conn_456')
channels = unify.chat.channels({'limit': 10})
print(f"Chat channels: {channels['data']}")
```

## Authentication

The BundleUp SDK uses API keys for authentication. You can obtain your API key from the [BundleUp Dashboard](https://app.bundleup.io).

### Getting Your API Key

1. Sign in to your [BundleUp Dashboard](https://app.bundleup.io)
2. Navigate to **API Keys**
3. Click **Create API Key**
4. Copy your API key and store it securely

### Initializing the SDK

```python theme={null}
from bundleup import BundleUp

# Initialize with API key
client = BundleUp('your_api_key_here')

# Or use environment variable (recommended)
import os
client = BundleUp(os.environ['BUNDLEUP_API_KEY'])

# Or use python-dotenv
from dotenv import load_dotenv
load_dotenv()
client = BundleUp(os.getenv('BUNDLEUP_API_KEY'))
```

### Security Best Practices

* ✅ **DO** store API keys in environment variables
* ✅ **DO** use a secrets management service in production
* ✅ **DO** rotate API keys regularly
* ❌ **DON'T** commit API keys to version control
* ❌ **DON'T** hardcode API keys in your source code
* ❌ **DON'T** share API keys in public channels

**`Example .env file:`**

```bash theme={null}
BUNDLEUP_API_KEY=Zw7Lt7...
```

**Loading environment variables:**

Install python-dotenv:

```bash theme={null}
pip install python-dotenv
```

Then in your application:

```python theme={null}
from dotenv import load_dotenv
import os

load_dotenv()

from bundleup import BundleUp
client = BundleUp(os.getenv('BUNDLEUP_API_KEY'))
```

**For Django applications:**

```python theme={null}
# settings.py
import os
from pathlib import Path

BUNDLEUP_API_KEY = os.environ.get('BUNDLEUP_API_KEY')

# views.py or services
from django.conf import settings
from bundleup import BundleUp

client = BundleUp(settings.BUNDLEUP_API_KEY)
```

**For Flask applications:**

```python theme={null}
# config.py
import os

class Config:
    BUNDLEUP_API_KEY = os.environ.get('BUNDLEUP_API_KEY')

# app.py
from flask import Flask
from bundleup import BundleUp

app = Flask(__name__)
app.config.from_object('config.Config')
client = BundleUp(app.config['BUNDLEUP_API_KEY'])
```

## Core Concepts

### Auth API

The **Auth API** runs the hosted authorization flow: build the URL that sends a user to connect an integration, then exchange the one-time `code` from the redirect for a `connection_id`. See [Authorization Flow](/docs/guides/auth/authorization-flow).

### Platform API

The **Platform API** provides access to core BundleUp features like managing connections and integrations. Use this API to list, retrieve, and delete connections, as well as discover available integrations.

### Proxy API

The **Proxy API** allows you to make direct HTTP requests to the underlying integration's API through BundleUp. This is useful when you need access to integration-specific features not covered by the Unified API.

### Unified API

The **Unified API** provides a standardized, normalized interface across different integrations. For example, you can fetch chat channels from Slack or Discord using the same API call.

### MCP API

The **MCP API** reaches a provider's own MCP server using a connection's stored credentials. Tools are defined by the provider, not by BundleUp. Because the connection is chosen per client, one agent can serve many end users without ever handling a token. See [MCP overview](/docs/guides/mcp/overview).

## API Reference

### Connections

Manage your integration connections.

#### List Connections

Retrieve a list of all connections in your account.

```python theme={null}
connections = client.connection.list()
```

**With query parameters:**

```python theme={null}
connections = client.connection.list({
    'integration_id': 'int_slack',
    'limit': 50,
    'offset': 0,
    'external_id': 'user_123'
})
```

**Query Parameters:**

* `integration_id` (str): Filter by integration ID
* `integration_identifier` (str): Filter by integration identifier (e.g., 'slack', 'github')
* `external_id` (str): Filter by external user/account ID
* `limit` (int): Maximum number of results (default: 50, max: 100)
* `offset` (int): Number of results to skip for pagination

**Response:**

```python theme={null}
[
    {
        'id': 'conn_123abc',
        'external_id': 'user_456',
        'integration_id': 'int_slack',
        'created_at': '2024-01-15T10:30:00Z',
        'updated_at': '2024-01-20T14:22:00Z',
        'expires_at': '2024-04-20T14:22:00Z'
    },
    # ... more connections
]
```

#### Retrieve a Connection

Get details of a specific connection by ID.

```python theme={null}
connection = client.connection.retrieve('conn_123abc')
```

**Response:**

```python theme={null}
{
    'id': 'conn_123abc',
    'external_id': 'user_456',
    'integration_id': 'int_slack',
    'created_at': '2024-01-15T10:30:00Z',
    'updated_at': '2024-01-20T14:22:00Z',
    'expires_at': '2024-04-20T14:22:00Z'
}
```

#### Delete a Connection

Remove a connection from your account.

```python theme={null}
client.connection.delete('conn_123abc')
```

**Note:** Deleting a connection will revoke access to the integration and cannot be undone.

### Integrations

Discover and work with available integrations.

#### List Integrations

Get a list of all available integrations.

```python theme={null}
integrations = client.integration.list()
```

**With query parameters:**

```python theme={null}
integrations = client.integration.list({
    'status': 'active',
    'limit': 100,
    'offset': 0
})
```

**Query Parameters:**

* `status` (str): Filter by status ('active', 'inactive', 'beta')
* `limit` (int): Maximum number of results
* `offset` (int): Number of results to skip for pagination

**Response:**

```python theme={null}
[
    {
        'id': 'int_slack',
        'identifier': 'slack',
        'name': 'Slack',
        'category': 'chat',
        'created_at': '2023-01-01T00:00:00Z',
        'updated_at': '2024-01-15T10:00:00Z'
    },
    # ... more integrations
]
```

#### Retrieve an Integration

Get details of a specific integration.

```python theme={null}
integration = client.integration.retrieve('int_slack')
```

**Response:**

```python theme={null}
{
    'id': 'int_slack',
    'identifier': 'slack',
    'name': 'Slack',
    'category': 'chat',
    'created_at': '2023-01-01T00:00:00Z',
    'updated_at': '2024-01-15T10:00:00Z'
}
```

### Webhooks

Manage webhook subscriptions for real-time event notifications.

#### List Webhooks

Get all registered webhooks.

```python theme={null}
webhooks = client.webhook.list()
```

**With pagination:**

```python theme={null}
webhooks = client.webhook.list({
    'limit': 50,
    'offset': 0
})
```

**Response:**

```python theme={null}
[
    {
        'id': 'webhook_123',
        'name': 'My Webhook',
        'url': 'https://example.com/webhook',
        'events': {
            'connection.created': True,
            'connection.deleted': True
        },
        'created_at': '2024-01-15T10:30:00Z',
        'updated_at': '2024-01-20T14:22:00Z',
        'last_triggered_at': '2024-01-20T14:22:00Z'
    }
]
```

#### Create a Webhook

Register a new webhook endpoint.

```python theme={null}
webhook = client.webhook.create({
    'name': 'Connection Events Webhook',
    'url': 'https://example.com/webhook',
    'events': {
        'connection.created': True,
        'connection.deleted': True,
        'connection.updated': True
    }
})
```

**Webhook Events:**

* `connection.created` - Triggered when a new connection is established
* `connection.deleted` - Triggered when a connection is removed
* `connection.updated` - Triggered when a connection is modified

**Request Body:**

* `name` (str): Friendly name for the webhook
* `url` (str): Your webhook endpoint URL
* `events` (dict): Events to subscribe to

**Response:**

```python theme={null}
{
    'id': 'webhook_123',
    'name': 'Connection Events Webhook',
    'url': 'https://example.com/webhook',
    'events': {
        'connection.created': True,
        'connection.deleted': True,
        'connection.updated': True
    },
    'created_at': '2024-01-15T10:30:00Z',
    'updated_at': '2024-01-15T10:30:00Z'
}
```

#### Retrieve a Webhook

Get details of a specific webhook.

```python theme={null}
webhook = client.webhook.retrieve('webhook_123')
```

#### Update a Webhook

Modify an existing webhook.

```python theme={null}
updated = client.webhook.update('webhook_123', {
    'name': 'Updated Webhook Name',
    'url': 'https://example.com/new-webhook',
    'events': {
        'connection.created': True,
        'connection.deleted': False
    }
})
```

#### Delete a Webhook

Remove a webhook subscription.

```python theme={null}
client.webhook.delete('webhook_123')
```

#### Webhook Payload Example

When an event occurs, BundleUp sends a POST request to your webhook URL with the following payload:

```json theme={null}
{
  "id": "evt_1234567890",
  "type": "connection.created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "id": "conn_123abc",
    "external_id": "user_456",
    "integration_id": "int_slack",
    "created_at": "2024-01-15T10:30:00Z"
  }
}
```

#### Webhook Security (Flask Example)

To verify webhook signatures in a Flask application:

```python theme={null}
from flask import Flask, request, jsonify
import hmac
import hashlib
import os

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('BundleUp-Signature')
    payload = request.get_data()

    if not verify_signature(payload, signature):
        return jsonify({'error': 'Invalid signature'}), 401

    event = request.get_json()
    process_webhook_event(event)

    return '', 200

def verify_signature(payload: bytes, signature: str) -> bool:
    secret = os.environ['BUNDLEUP_WEBHOOK_SECRET']
    computed = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(computed, signature)

def process_webhook_event(event: dict):
    event_type = event['type']

    if event_type == 'connection.created':
        handle_connection_created(event['data'])
    elif event_type == 'connection.deleted':
        handle_connection_deleted(event['data'])
    elif event_type == 'connection.updated':
        handle_connection_updated(event['data'])
    elif event_type == 'connection.expired':
        handle_connection_expired(event['data'])
```

**Django Example:**

```python theme={null}
# views.py
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
import hmac
import hashlib
import json

@csrf_exempt
def bundleup_webhook(request):
    if request.method != 'POST':
        return HttpResponseForbidden()

    signature = request.META.get('HTTP_BUNDLEUP_SIGNATURE')
    payload = request.body

    if not verify_signature(payload, signature):
        return HttpResponseForbidden('Invalid signature')

    event = json.loads(payload)
    process_webhook_event(event)

    return HttpResponse(status=200)

def verify_signature(payload: bytes, signature: str) -> bool:
    secret = settings.BUNDLEUP_WEBHOOK_SECRET
    computed = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(computed, signature)
```

### Auth API

Connect an end user's account and get back a `connection_id`. See [Authorization Flow](/docs/guides/auth/authorization-flow) for the full flow.

#### Build the Authorization URL

```python theme={null}
url = client.auth.build_authorization_url(
    client_id='your-client-id',
    integration_id='github',
    redirect_uri='https://app.example.com/callback',
    external_id='user_42',  # optional
    state='random-csrf-token'  # optional
)

# Redirect the user to `url`
```

The URL needs no API key, so you can also build it without a client:

```python theme={null}
from bundleup import build_authorization_url

url = build_authorization_url(
    client_id='your-client-id',
    integration_id='github',
    redirect_uri='https://app.example.com/callback',
)
```

**Parameters:**

* `client_id` (str, required): Your workspace client ID
* `integration_id` (str, required): The integration to connect
* `redirect_uri` (str, required): Must exactly match a redirect URI registered in your dashboard
* `external_id` (str, optional): Your own reference, stored on the connection
* `state` (str, optional): Returned unchanged on the redirect

#### Exchange the Code for a Connection

BundleUp redirects back to `redirect_uri` with a one-time `code`. Exchange it server-side:

```python theme={null}
connection = client.auth.get_connection_from_code(
    code=request.args['code'],
    redirect_uri='https://app.example.com/callback'
)

print(connection['connection_id'])
```

**Parameters:**

* `code` (str, required): The `code` query parameter from the redirect
* `redirect_uri` (str, required): The same redirect URI used to build the authorization URL

**Response:**

```python theme={null}
{
    'connection_id': 'conn_abc123',
    'external_id': 'user_42',
    'integration_id': 'github'
}
```

The code expires after 5 minutes and can only be exchanged once. An invalid, expired or reused code raises an `Exception` that includes the API's error body. Missing arguments raise `ValueError`.

### Proxy API

Make direct HTTP requests to integration APIs through BundleUp.

#### Creating a Proxy Instance

```python theme={null}
proxy = client.proxy('conn_123abc')
```

#### GET Request

```python theme={null}
response = proxy.get('/api/users')
data = response.json()
print(data)
```

**With query parameters:**

```python theme={null}
response = proxy.get('/api/users', params={'limit': 10})
```

**With custom headers:**

```python theme={null}
response = proxy.get('/api/users', headers={
    'X-Custom-Header': 'value',
    'Accept': 'application/json'
})
```

#### POST Request

```python theme={null}
response = proxy.post('/api/users', body={
    'name': 'John Doe',
    'email': 'john@example.com',
    'role': 'developer'
})

new_user = response.json()
print(f"Created user: {new_user}")
```

**With custom headers:**

```python theme={null}
response = proxy.post(
    '/api/users',
    body={'name': 'John Doe'},
    headers={
        'Content-Type': 'application/json',
        'X-API-Version': '2.0'
    }
)
```

#### PUT Request

```python theme={null}
response = proxy.put('/api/users/123', body={
    'name': 'Jane Doe',
    'email': 'jane@example.com'
})

updated_user = response.json()
```

#### PATCH Request

```python theme={null}
response = proxy.patch('/api/users/123', body={
    'email': 'newemail@example.com'
})

partially_updated = response.json()
```

#### DELETE Request

```python theme={null}
response = proxy.delete('/api/users/123')

if response.ok:
    print('User deleted successfully')
```

#### Working with Response Objects

The Proxy API returns requests Response objects:

```python theme={null}
response = proxy.get('/api/users')

# Access response body as JSON
data = response.json()

# Check status code
print(response.status_code)  # 200

# Check if successful
print(response.ok)  # True

# Access headers
print(response.headers['content-type'])

# Access raw content
content = response.content

# Access text
text = response.text

# Handle errors
try:
    response = proxy.get('/api/invalid')
    response.raise_for_status()
except requests.HTTPError as e:
    print(f"Request failed: {e}")
```

### Unified API

Access unified, normalized data across different integrations with a consistent interface.

#### Creating a Unified API Instance

```python theme={null}
unify = client.unify('conn_123abc')
```

#### Chat API

The Chat API provides a unified interface for chat platforms such as Slack and Discord. See [Chat](/docs/guides/unified/chat).

##### List Channels

Retrieve a list of channels from the connected chat platform.

```python theme={null}
result = unify.chat.channels({
    'limit': 100,
    'after': None,
    'include_raw': False
})

print(f"Channels: {result['data']}")
print(f"Next cursor: {result['metadata']['next']}")
```

**Parameters:**

* `limit` (int, optional): Maximum number of channels to return (default: 100, max: 1000)
* `after` (str, optional): Pagination cursor from previous response
* `include_raw` (bool, optional): Include raw API response from the integration (default: False)

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': 'C1234567890',
            'name': 'general'
        },
        {
            'id': 'C0987654321',
            'name': 'engineering'
        }
    ],
    'metadata': {
        'next': 'cursor_abc123'  # Use this for pagination
    },
    '_raw': {  # Only present if include_raw=True
        # Original response from the integration API
    }
}
```

**Pagination example:**

```python theme={null}
all_channels = []
cursor = None

while True:
    result = unify.chat.channels({
        'limit': 100,
        'after': cursor
    })

    all_channels.extend(result['data'])
    cursor = result['metadata']['next']

    if cursor is None:
        break

print(f"Fetched {len(all_channels)} total channels")
```

#### Git API

The Git API provides a unified interface for version control platforms like GitHub, GitLab, and Bitbucket.

##### List Repositories

```python theme={null}
result = unify.git.repos({
    'limit': 50,
    'after': None,
    'include_raw': False
})

print(f"Repositories: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': '123456',
            'name': 'my-awesome-project',
            'full_name': 'organization/my-awesome-project',
            'description': 'An awesome project',
            'url': 'https://github.com/organization/my-awesome-project',
            'created_at': '2023-01-15T10:30:00Z',
            'updated_at': '2024-01-20T14:22:00Z',
            'pushed_at': '2024-01-20T14:22:00Z'
        }
    ],
    'metadata': {
        'next': 'cursor_xyz789'
    }
}
```

##### List Pull Requests

```python theme={null}
result = unify.git.pulls('organization/repo-name', {
    'limit': 20,
    'after': None,
    'include_raw': False
})

print(f"Pull Requests: {result['data']}")
```

**Parameters:**

* `repo_name` (str, required): Repository name in the format 'owner/repo'
* `limit` (int, optional): Maximum number of PRs to return
* `after` (str, optional): Pagination cursor
* `include_raw` (bool, optional): Include raw API response

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': '12345',
            'number': 42,
            'title': 'Add new feature',
            'description': 'This PR adds an awesome new feature',
            'draft': False,
            'state': 'open',
            'url': 'https://github.com/org/repo/pull/42',
            'user': 'john-doe',
            'created_at': '2024-01-15T10:30:00Z',
            'updated_at': '2024-01-20T14:22:00Z',
            'merged_at': None
        }
    ],
    'metadata': {
        'next': None
    }
}
```

##### List Issues

```python theme={null}
result = unify.git.issues('organization/repo-name', {'limit': 20})

print(f"Issues: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': 67890,
            'number': 17,
            'title': 'Timestamps drift on retry',
            'description': 'Retried requests report the first attempt time',
            'state': 'open',
            'url': 'https://github.com/org/repo/issues/17',
            'user': 'john-doe',
            'created_at': '2024-01-15T10:30:00Z',
            'updated_at': '2024-01-20T14:22:00Z',
            'closed_at': None
        }
    ],
    'metadata': {
        'next': None
    }
}
```

##### List Tags

```python theme={null}
result = unify.git.tags('organization/repo-name', {'limit': 50})

print(f"Tags: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'name': 'v1.0.0',
            'commit_sha': 'abc123def456'
        },
        {
            'name': 'v0.9.0',
            'commit_sha': 'def456ghi789'
        }
    ],
    'metadata': {
        'next': None
    }
}
```

##### List Releases

```python theme={null}
result = unify.git.releases('organization/repo-name', {'limit': 10})

print(f"Releases: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': '54321',
            'name': 'Version 1.0.0',
            'tag_name': 'v1.0.0',
            'description': 'Initial release with all the features',
            'prerelease': False,
            'url': 'https://github.com/org/repo/releases/tag/v1.0.0',
            'created_at': '2024-01-15T10:30:00Z',
            'released_at': '2024-01-15T10:30:00Z'
        }
    ],
    'metadata': {
        'next': None
    }
}
```

##### List Commits

```python theme={null}
result = unify.git.commits('organization/repo-name', {'branch': 'main', 'limit': 20})

print(f"Commits: {result['data']}")
```

`branch` is optional and accepts a branch name, tag or commit SHA. When it is omitted the
provider's default branch is used.

**Response:**

```python theme={null}
{
    'data': [
        {
            'sha': 'abc123def4567890abc123def4567890abc123de',
            'message': 'Add commits endpoint',
            'url': 'https://github.com/org/repo/commit/abc123def4567890abc123def4567890abc123de',
            'author': 'Jane Doe',
            'author_email': 'jane@example.com',
            'committed_at': '2024-01-15T10:30:00Z'
        }
    ],
    'metadata': {
        'next': None
    }
}
```

#### Tasks API

The Tasks API provides a unified interface for issue trackers and project management tools like Jira, Linear, Asana, Trello, ClickUp, monday.com, Basecamp, Wrike, and Todoist.

##### List Tasks

```python theme={null}
result = unify.tasks.tasks({
    'limit': 100,
    'after': None,
    'include_raw': False
})

print(f"Tasks: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': 'PROJ-123',
            'url': 'https://jira.example.com/browse/PROJ-123',
            'title': 'Fix login bug',
            'status': 'in_progress',
            'description': 'Users are unable to log in',
            'created_at': '2024-01-15T10:30:00Z',
            'updated_at': '2024-01-20T14:22:00Z'
        }
    ],
    'metadata': {
        'next': 'cursor_def456'
    }
}
```

**Note:** Trello has no endpoint that lists every card, so it returns the cards the connected member is on. To list every card on a board instead, send the board id through the `params` passthrough parameter as `{"board": "5abbe4b7ddc1b351ef961414"}`. Trello also returns its collections in a single page, so `metadata['next']` is always `None` for Trello connections.

**Filtering and sorting:**

```python theme={null}
open_tasks = [task for task in result['data'] if task['status'] == 'open']
sorted_by_date = sorted(
    result['data'],
    key=lambda x: x['created_at'],
    reverse=True
)
```

##### List Projects

```python theme={null}
result = unify.tasks.projects({
    'limit': 100,
    'after': None,
    'include_raw': False
})

print(f"Projects: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': '10001',
            'name': 'Website Redesign',
            'status': 'active',
            'url': 'https://jira.example.com/browse/PROJ',
            'description': 'All the work for the new marketing site',
            'created_at': '2024-01-15T10:30:00Z',
            'updated_at': '2024-01-20T14:22:00Z'
        }
    ],
    'metadata': {
        'next': 'cursor_def456'
    }
}
```

**Note:** Not every platform returns every field. Jira does not expose creation or update timestamps for projects, so `created_at` and `updated_at` are `None` for Jira connections, and `status` is only set when Jira reports whether the project is archived.

#### Helpdesk API

The Helpdesk API provides a unified interface for customer support tools like Zendesk, ServiceNow, Zoho Desk, Gorgias, and Help Scout.

##### List Tickets

```python theme={null}
result = unify.helpdesk.tickets({
    'limit': 100,
    'after': None,
    'include_raw': False
})

print(f"Tickets: {result['data']}")
```

**Response:**

```python theme={null}
{
    'data': [
        {
            'id': '4182',
            'title': 'Refund for order #1043',
            'description': 'Hi, my order arrived damaged...',
            'status': 'open',
            'priority': 'high',
            'requester': {'id': '381920', 'name': 'Dana Ruiz', 'email': 'dana@example.com'},
            'url': 'https://acme.zendesk.com/agent/tickets/4182',
            'created_at': '2024-01-15T10:30:00Z',
            'updated_at': '2024-01-20T14:22:00Z'
        }
    ],
    'metadata': {
        'next': 'cursor_def456'
    }
}
```

##### Get a Ticket

Fetch one support ticket by ID. On ServiceNow the ID is the incident's `sys_id`.

```python theme={null}
result = unify.helpdesk.ticket('4182')

print(f"Ticket: {result['data']}")
```

A single resource carries no pagination, so there is no `metadata` on this response.

### MCP API

Reach a provider's own MCP server using a connection's credentials. BundleUp injects and refreshes the access token, so the connection ID is the only thing your agent needs to know about a user.

Supported for providers that run a first-party MCP server — see the [integrations page](https://www.bundleup.io/integrations). Others return an `mcp_not_supported` error.

BundleUp does not ship an MCP client. Hand `hosted()` or `transport()` to the one you already use, or send JSON-RPC yourself with `post` and `delete`, which return the response untouched as `requests.Response` objects, like the Proxy API.

#### Creating an MCP Instance

```python theme={null}
mcp = client.mcp('conn_123abc')
```

#### Model-Hosted MCP

OpenAI and Anthropic can connect to an MCP server themselves, with no tool mapping or dispatch loop on your side. Both accept only a single credential and no custom headers, so `hosted()` returns the server URL alongside the API key and connection joined into one bearer.

```python theme={null}
hosted = client.mcp('conn_123abc').hosted()

response = openai.responses.create(
    model='gpt-4o',
    input='What issues are assigned to me?',
    tools=[
        {
            'type': 'mcp',
            'server_label': 'linear',
            'server_url': hosted['url'],
            'authorization': hosted['token'],
            'require_approval': 'never',
        }
    ],
)
```

Anthropic's connector takes the same pair as `url` and `authorization_token`.

`server_url` must be exactly the URL `hosted()` returns — the proxy rebuilds the upstream URL from the provider's own base, so any path or query you append is ignored rather than rejected.

This sends your API key to the model provider, whose servers make the request. Use an MCP client in your own backend if that is not acceptable.

#### Using an MCP Client Library

`transport()` returns the URL and headers if you would rather use an existing MCP client, such as the official [`mcp`](https://pypi.org/project/mcp/) package.

```python theme={null}
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

transport = client.mcp('conn_123abc').transport()

async with streamablehttp_client(transport['url'], headers=transport['headers']) as (read, write, _):
    async with ClientSession(read, write) as session:
        await session.initialize()
        result = await session.list_tools()
```

#### Sending JSON-RPC Directly

MCP requires an `initialize` handshake before any other method. The session ID comes back on that first response and must be sent on every call after it.

```python theme={null}
mcp = client.mcp('conn_123abc')

# 1. Handshake
init = mcp.post({
    'jsonrpc': '2.0',
    'id': 1,
    'method': 'initialize',
    'params': {
        'protocolVersion': '2025-06-18',
        'capabilities': {},
        'clientInfo': {'name': 'my-agent', 'version': '1.0.0'},
    },
})

session_id = init.headers.get('mcp-session-id')
session = {'Mcp-Session-Id': session_id} if session_id else {}

# 2. Confirm the handshake (a notification — no id, no response body)
mcp.post({'jsonrpc': '2.0', 'method': 'notifications/initialized'}, session)

# 3. List tools
response = mcp.post({'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}, session)
result = parse(response)['result']

print(result['tools'])
```

Providers may answer with `text/event-stream` rather than JSON, so responses need unwrapping either way:

```python theme={null}
import json

def parse(response):
    if 'text/event-stream' not in response.headers.get('content-type', ''):
        return response.json()

    data = '\n'.join(
        line[5:].strip() for line in response.text.splitlines() if line.startswith('data:')
    )

    return json.loads(data)
```

Tool lists can be paginated. If `result['nextCursor']` is set, call `tools/list` again with `'params': {'cursor': result['nextCursor']}` until it comes back empty.

Calling a tool follows the same shape:

```python theme={null}
response = mcp.post({
    'jsonrpc': '2.0',
    'id': 3,
    'method': 'tools/call',
    'params': {'name': 'create_issue', 'arguments': {'title': 'Login broken'}},
}, session)
```

#### Sessions

MCP sessions live on the provider's server — BundleUp holds no session state. Close one when you are done:

```python theme={null}
mcp.delete({'Mcp-Session-Id': session_id})
```

#### Errors

BundleUp rejects a request before it reaches the provider by returning an HTTP error with a JSON body — the response is passed straight through, so check `response.ok` yourself.

```python theme={null}
response = mcp.post(body)

if not response.ok:
    error = response.json()
    # connection_invalid, connection_refresh_failed, mcp_not_supported, rate_limit
    print(error['code'], error['message'])
```

Every JSON-RPC message counts toward the rate limit of 100 requests per 60 seconds, per connection — including the `initialize` handshake.

#### Several Connections

An agent often needs more than one provider for the same end user. With model-hosted MCP there is nothing to merge — pass one `mcp` tool per connection and the model provider keeps them apart by `server_label`:

```python theme={null}
connections = {'slack': user.slack_connection, 'linear': user.linear_connection}

tools = []

for label, connection_id in connections.items():
    hosted = client.mcp(connection_id).hosted()

    tools.append({
        'type': 'mcp',
        'server_label': label,
        'server_url': hosted['url'],
        'authorization': hosted['token'],
        'require_approval': 'never',
    })
```

With an MCP client in your own backend, open one client per connection from its `transport()`, prefix each tool name with a label (`slack__send_message`), and route calls back by that prefix. There is no merge helper in the SDK — how tools are namespaced, filtered and recovered from differs enough per agent that it is better written where you can see it.

**Filter before you hand tools to a model** — three providers is easily sixty tools, and accuracy drops as that list grows, so give the agent only the ones it needs.

## Error Handling

The SDK raises exceptions for errors. Always wrap SDK calls in try-except blocks for proper error handling.

```python theme={null}
try:
    connections = client.connection.list()
except Exception as e:
    print(f"Failed to fetch connections: {e}")
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.