---
title: Call third-party APIs from Python with Vercel Connect
description: Call Slack and Linear APIs from Python with Vercel Connect. Request app and user tokens, handle consent, and verify provider events in FastAPI.
url: "https://vercel.com/kb/guide/python-and-vercel-connect"
published: 2026-10-26
last_updated: 2026-10-26
authors: Ben Sabic
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

When your Python app posts a deployment update to Slack or creates a Linear issue for a user, each request needs the right credentials. Access may belong to the app or to an individual user who has authorized the integration.

Use [Vercel Connect](https://vercel.com/docs/connect) to request those credentials from Python without storing provider secrets in your project's environment. The SDK returns a token that your app uses to call the provider's API directly.

Vercel handles OAuth registration and token refresh for managed connectors such as Slack and Linear. Your application authenticates its users and decides which actions they can perform with the access they've granted.

## What is Vercel Connect?

[Vercel Connect](https://vercel.com/docs/connect) is a credential broker for third-party APIs. You create a connector for a provider, link it to your project, and request credentials from your application.

For managed connectors such as Slack and Linear, Vercel registers the OAuth client and handles authorization and refresh. Custom OAuth and API-key connectors use credentials you supply. Connect stores those credentials so your project doesn't need to keep them in environment variables.

The Python SDK is [`vercel-connect`](https://vercel.com/docs/connect/python-sdk-reference), imported as `vercel.connect`. It's `async` first, with a synchronous mirror in `vercel.connect.sync`.

Your app exchanges its deployment identity for a token to call the provider directly. For inbound events, Connect verifies and forwards provider webhooks. Your route must verify those forwarded requests before processing them.

### Connectors, subjects, and triggers

The examples use three Connect concepts:

| Idea      | What it means                                                                                 | In the SDK                                                    |
| --------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Connector | Team-owned record for one provider, addressed by a UID you choose, such as `slack/acme-slack` | UID passed to token and authorization calls                   |
| Subject   | Whose authority the token carries: your app, a specific user, or a federated identity         | `ConnectAppTokenSubject()`, `ConnectUserTokenSubject(id=...)` |
| Trigger   | Provider webhook that Vercel Connect verifies and forwards to your app                        | `verify_connect_webhook(request)`                             |

Tokens are the outbound half: your app calling the provider. Triggers are the inbound half: the provider calling your app.

### How your app proves who it is

Vercel gives each deployment an OIDC token identifying its team, project, and environment. Connect checks that identity against the connector's project link before issuing a provider credential.

The provider determines which permissions and expiry rules apply. Request the narrowest supported access and keep returned tokens out of logs and client responses. Returned tokens can remain usable until they expire or the provider revokes them; they are not restricted to a single API call.

For local development, `vercel env pull` downloads an OIDC token into `.env.local`. Step 2 loads that file with `python-dotenv` so the SDK can resolve the token.

## Prerequisites

Before you begin, make sure you have:

- A [Vercel account](https://vercel.com/signup).
  
- Python 3.12 or a newer version supported by the [Vercel Python runtime](https://vercel.com/docs/functions/runtimes/python).
  
- Working knowledge of `async`/`await` and [FastAPI](https://fastapi.tiangolo.com/).
  
- A Slack workspace where you can install apps, plus a Linear workspace
  
- An authentication dependency that checks access and returns a stable user ID.
  

For local development, you also need [uv](https://docs.astral.sh/uv/), Node.js 24+, and the [Vercel CLI](https://vercel.com/docs/cli).

The examples use Slack and Linear connectors managed by Vercel. Other providers use the same token-request API, but their supported subjects, permissions, installation requirements, and API calls can differ.

## Step 1: Create and link a connector

Create a Slack connector for your team and link it to your project:

```bash
uv init slack-notifier --python 3.12
cd slack-notifier
vercel link
vercel connect create slack --name acme-slack
vercel connect attach slack/acme-slack
```

`create` opens your browser to install Vercel's Slack app and prints its connector UID. `attach` then links the connector to your project's Production, Preview, and Development environments unless you select one with `--``environment`.

## Step 2: Post to Slack as your app

Add the SDK and pull a development OIDC token:

```bash
uv add vercel-connect httpx python-dotenv
vercel env pull
```

The SDK needs the development OIDC token in its process environment. The script below loads `.env.local` with `python-dotenv`. If it cannot resolve credentials, check that the file was loaded and refresh it with `vercel env pull` when needed.

Save this as `post_message.py`:

```python
import asyncio

import httpx
from dotenv import load_dotenv
from vercel import connect
from vercel.connect import ConnectAppTokenSubject

async def post_message(text: str) -> dict:
    token = await connect.get_token(
        "slack/acme-slack",
        subject=ConnectAppTokenSubject(),
        scopes=["chat:write"],
    )
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://slack.com/api/chat.postMessage",
            headers={"Authorization": f"Bearer {token}"},
            json={"channel": "C0123456789", "text": text},
        )
    response.raise_for_status()
    payload = response.json()
    if not payload.get("ok"):
        raise RuntimeError(f"Slack rejected the message: {payload.get('error', 'unknown_error')}")
    return payload


if __name__ == "__main__":
    load_dotenv(".env.local")
    print(asyncio.run(post_message("Deploy finished")))
```

Replace the channel ID with one your Slack app has joined, then run the script:

```bash
uv run python post_message.py
```

The script sends "Deploy finished" and prints Slack's response. It raises an error if the HTTP request or Slack operation fails.

Use `ConnectAppTokenSubject()` to request a token for the installed Slack app. The `chat:write` permission lets it post messages. You can only request permissions supported by both Slack and the connector.

Pass scopes as a list, such as `["chat:write"]`. Passing `"chat:write"` on its own raises `ConnectValidationError`.

Request a token immediately before the provider call. The SDK reuses matching tokens from its in-memory cache until they enter the default 30-second expiry buffer. Cache misses and explicit refresh requests fetch a credential from Connect.

## Step 3: Act on behalf of a user

Use a user subject to create Linear issues under the requester's account once they have authorized the integration.

Create a Linear connector and link it, the same way as Slack:

```bash
vercel connect create linear --name acme-linear
vercel connect attach linear/acme-linear
```

_Vercel manages Linear's OAuth app too, so there's no client ID or secret to register._

Save the following helper functions as `integrations.py`. The user subject identifies the signed-in user in your application. Derive `user_id` from your authentication system so callers cannot request another person's provider access.

```python
import httpx
from vercel import connect
from vercel.connect import ConnectUserTokenSubject

LINEAR = "linear/acme-linear"

ISSUE_CREATE = """
mutation IssueCreate($input: IssueCreateInput!) {
  issueCreate(input: $input) {
    success
    issue { identifier url }
  }
}
"""


async def create_issue(*, user_id: str, team_id: str, title: str) -> dict:
    token = await connect.get_token(LINEAR, subject=ConnectUserTokenSubject(id=user_id))
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.linear.app/graphql",
            headers={"Authorization": f"Bearer {token}"},
            json={
                "query": ISSUE_CREATE,
                "variables": {"input": {"teamId": team_id, "title": title}},
            },
        )
    response.raise_for_status()
    payload = response.json()
    if payload.get("errors"):
        raise RuntimeError("Linear returned a GraphQL error")
    result = (payload.get("data") or {}).get("issueCreate") or {}
    if not result.get("success") or not result.get("issue"):
        raise RuntimeError("Linear did not confirm issue creation")
    return result["issue"]


async def linear_consent_url(*, user_id: str, return_url: str) -> str:
    authorization = await connect.start_authorization(
        LINEAR,
        subject=ConnectUserTokenSubject(id=user_id),
        return_url=return_url,
    )
    return authorization.url
```

Use a Linear team UUID for `team_id`. In Linear, open the team and use the command menu's Copy model UUID action. The helper checks both the HTTP response and the GraphQL result before returning the created issue.

When the user has not authorized the connector, token retrieval raises `UserAuthorizationRequiredError`. Send them to the URL returned by `linear_consent_url()` to approve access on Linear's consent screen.

Connect stores the grant and redirects the user back to your app. Subsequent requests use that grant, with refresh handled by Connect when supported. Revoked or invalid grants require the user to connect again.

`return_url` must use `https://`, or `http://` on `localhost` or `127.0.0.1` .

## Step 4: Handle what can go wrong

Catch Connect errors by type. Provider HTTP failures and network errors need separate handling, as the Slack and Linear helpers demonstrate.

| Error                                    | What happened                                           | What to do                                                                               |
| ---------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `UserAuthorizationRequiredError`         | The user has not authorized the connector               | Start authorization and send the user to its URL                                         |
| `NoValidTokenError`, `InvalidGrantError` | Connect cannot issue a usable credential from the grant | For a user subject, ask the user to reconnect; check the installation for an app subject |
| `ConnectorInstallationRequiredError`     | The requested connector installation is missing         | Have an administrator install or configure it                                            |
| `ConnectApiError`                        | Another Connect API request failed                      | Inspect `status_code`, `code`, and `request_id` without logging credentials              |
| `ConnectCredentialsError`                | The SDK could not resolve its Vercel identity           | Check environment loading, project linking, and local OIDC credentials                   |
| `ConnectValidationError`                 | A supplied argument is invalid                          | Correct the argument before retrying                                                     |

Providers can reject a cached credential before its expiry. Slack may report this through an `ok: false` response with an authentication error such as `invalid_auth`; checking for HTTP `401` alone is insufficient.

After confirming a credential rejection, clear its cached entry and request another token with the same permissions. This helper refreshes the credential without repeating the message automatically:

```python
from vercel import connect
from vercel.connect import ConnectAppTokenSubject


async def refresh_slack_token() -> str:
    subject = ConnectAppTokenSubject()
    connect.delete_token_cache_entry("slack/acme-slack", subject=subject)
    return await connect.get_token(
        "slack/acme-slack", subject=subject, scopes=["chat:write"]
    )
```

Retry the operation at most once after a confirmed authentication rejection. Check the outcome before repeating a write after a timeout or other ambiguous failure.

`delete_token_cache_entry()` _is synchronous, since it only touches the local cache. It doesn't revoke anything._

Use `get_token_response()` when you need credential metadata as well as the token. It accepts the same arguments and includes `expires_at`.

The optional `name` and `tenant_id` fields can help identify a connected workspace. Check for `None` before displaying either field.

## Step 5: Serve it from FastAPI and deploy

The FastAPI app imports `post_message.py` and `integrations.py` from the earlier steps. Keep all three files in the project root and install FastAPI:

```bash
uv add fastapi
```

Provide `auth.py` with your application's `current_user_id` dependency. It must reject callers who cannot use this integration app and return a stable ID for each authorized user. The app subject gives access to the shared Slack bot, so this check applies to the notification route too.

Set `APP_BASE_URL` to a trusted application origin, such as `http://localhost:3000` locally or your HTTPS deployment URL on Vercel. The consent redirect below uses that configured origin. Save the app as `main.py`:

```python
import os

import httpx
import pydantic
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from vercel import connect
from vercel.connect import (
    ConnectUserTokenSubject,
    ConnectWebhookVerificationError,
    InvalidGrantError,
    NoValidTokenError,
    UserAuthorizationRequiredError,
    verify_connect_webhook,
)

load_dotenv(".env.local")

from auth import current_user_id
from integrations import LINEAR, create_issue, linear_consent_url
from post_message import post_message

APP_BASE_URL = os.environ["APP_BASE_URL"].rstrip("/")
app = FastAPI()


class NotificationRequest(pydantic.BaseModel):
    text: str = pydantic.Field(min_length=1, max_length=4000)


class IssueRequest(pydantic.BaseModel):
    team_id: str
    title: str = pydantic.Field(min_length=1)


@app.post("/api/notify")
async def notify(body: NotificationRequest, user_id: str = Depends(current_user_id)):
    try:
        result = await post_message(body.text)
    except (httpx.HTTPError, RuntimeError) as exc:
        raise HTTPException(status_code=502, detail="Slack request failed") from exc
    return {"ok": True, "ts": result["ts"]}


@app.post("/api/issues")
async def file_issue(body: IssueRequest, user_id: str = Depends(current_user_id)):
    try:
        return await create_issue(user_id=user_id, team_id=body.team_id, title=body.title)
    except (UserAuthorizationRequiredError, NoValidTokenError, InvalidGrantError) as exc:
        raise HTTPException(
            status_code=409, detail={"connect_url": "/api/integrations/linear"}
        ) from exc
    except (httpx.HTTPError, RuntimeError) as exc:
        raise HTTPException(status_code=502, detail="Linear request failed") from exc


@app.get("/api/integrations/linear")
async def connect_linear(user_id: str = Depends(current_user_id)):
    url = await linear_consent_url(user_id=user_id, return_url=f"{APP_BASE_URL}/settings")
    return RedirectResponse(url)


@app.delete("/api/integrations/linear")
async def disconnect_linear(user_id: str = Depends(current_user_id)):
    await connect.revoke_token(LINEAR, subject=ConnectUserTokenSubject(id=user_id))
    return {"ok": True}


@app.get("/settings")
async def settings(user_id: str = Depends(current_user_id)):
    return {"message": "Return to the app to retry your Linear action."}


@app.post("/api/slack-events")
async def slack_events(request: Request):
    try:
        await verify_connect_webhook(request)
    except ConnectWebhookVerificationError as exc:
        raise HTTPException(status_code=401) from exc
    event = await request.json()
    # Add event validation, deduplication, and processing before acknowledging.
    return {"ok": True}
```

The settings route is a minimal consent landing page. Replace it with your application's settings UI. The Slack route verifies and acknowledges events but does not process them yet; add your handler before relying on it for delivery.

The routes use the earlier helpers as follows:

- `/api/notify` accepts a JSON `text` field and sends through the shared Slack app after authenticating the caller.
  
- `/api/issues` creates an issue as the signed-in user. If authorization is missing or unusable, its `409` response includes a connection URL for your frontend to open.
  
- `/api/integrations/linear` starts consent and returns the user to `/settings`. `DELETE` on the same path removes the stored authorization and clears cached tokens for that user.
  
- `/api/slack-events` verifies forwarded Slack events before reading their payload.
  

Provider-side revocation varies. Previously issued credentials may remain valid until expiry if the provider cannot revoke them.

### Receive Slack events with triggers

Connect verifies provider signatures and forwards events with a Vercel OIDC bearer token. Use `verify_connect_webhook()` before handling an event.

On Vercel, the verifier reads the expected project and environment from `VERCEL_PROJECT_ID` and `VERCEL_TARGET_ENV`. It rejects the request if those expectations cannot be resolved. Your handler still needs to validate event fields and prevent duplicate processing.

Tell Vercel Connect where to send Slack's events:

```bash
vercel connect attach slack/acme-slack --triggers --trigger-path /api/slack-events
```

This command registers a Production destination at `/api/slack-events`. To target a Preview branch, add `--trigger-branch` followed by the branch name.

Vercel Connect forwards to deployed URLs, not to `localhost`. Test triggers against a Preview or Production deployment. If your app returns a `500`, `502`, `503`, or `504`, delivery is retried, so make event handling safe to run twice.

### Deploy

With `auth.py` implemented, set `APP_BASE_URL` to `http://localhost:3000` in `.env.local` and start the server:

```bash
vercel dev
```

Use your application's authenticated client to send `{"text":"Deploy finished"}` to `POST /api/notify`. Confirm that the message appears in Slack. For Linear, send a team UUID and title to `POST /api/issues`; follow the connection URL if required, then retry after consent.

Configure `APP_BASE_URL` and your application authentication settings for each deployment environment. Commit `uv.lock` and deploy:

```bash
vercel deploy        # preview
vercel deploy --prod # production
```

The Vercel Python runtime supplies deployment OIDC identity to the SDK. You do not need to add Slack or Linear provider credentials to the project's environment.

The SDK resolves its Vercel identity on each call and keeps provider cache entries associated with that identity when the OIDC token rotates. If you supply your own credential, use a resolver that returns a current value rather than capturing an expiring token once.

## Use cases

The same token and authorization APIs support these integrations:

| Use case                      | Building blocks                            | Example |
| ----------------------------- | ------------------------------------------ | ------- |
| Bots and notifications        | App subject                                | Step 2  |
| Actions in a user's name      | User subject, `start_authorization()`      | Step 3  |
| Reacting to provider events   | Triggers, `verify_connect_webhook()`       | Step 5  |
| AI agents with real tools     | User subject inside an AI SDK tool         | Below   |
| Sandboxed code calling an API | Token injected by a Sandbox network policy | Below   |
| CLIs and background jobs      | `device_code=True` and polling             | Below   |
| Durable workflows             | `get_token()` inside each step             | Below   |

### Give an AI agent tools that act as the user

Install the [AI SDK for Python](https://ai-python.dev/docs) before adding this optional integration:

```bash
uv add ai
```

Build the agent after authenticating the request. The factory below binds its tool to that user's ID and requires approval before creating an issue:

```python
import ai

from integrations import create_issue


def agent_for(user_id: str) -> ai.Agent:
    @ai.tool(require_approval=True)
    async def file_linear_issue(team_id: str, title: str) -> str:
        """Create a Linear issue as the current user."""
        issue = await create_issue(user_id=user_id, team_id=team_id, title=title)
        return f"Created {issue['identifier']}: {issue['url']}"

    return ai.Agent(tools=[file_linear_issue])
```

Call `agent_for()` with the ID from your authentication dependency. The tool keeps the provider credential on the server. Enforce any additional application restrictions on team selection before calling Linear.

Connect the SDK's approval handling to your application so the user can approve or reject the proposed issue. Setting `require_approval=True` marks the operation for approval; your application must present and resolve that request.

### Let sandboxed code call an API without seeing the key

Install `vercel-sandbox` and create a GitHub connector linked to this project before using this optional example. Replace `github/acme-github` and `acme/api` with your connector and a repository included in its installation.

```bash
uv add vercel-sandbox
```

The [Sandbox network policy](https://vercel.com/docs/sandbox/python-sdk-reference) injects the credential into requests to GitHub. The sandboxed program receives API responses without being given the token as an environment variable or file:

```python
from vercel import connect, sandbox
from vercel.connect import (
    ConnectAppTokenSubject,
    ConnectGitHubAppInstallationAuthorizationDetail,
)
from vercel.sandbox import NetworkPolicy, NetworkPolicyRule, NetworkPolicyTransform


async def read_repository() -> str:
    token = await connect.get_token(
        "github/acme-github",
        subject=ConnectAppTokenSubject(),
        authorization_details=[
            ConnectGitHubAppInstallationAuthorizationDetail(
                permissions=["contents:read"], repositories=["acme/api"]
            )
        ],
    )
    policy = NetworkPolicy.custom(
        allow={
            "api.github.com": [
                NetworkPolicyRule(
                    transform=[
                        NetworkPolicyTransform(headers={"Authorization": f"Bearer {token}"})
                    ]
                )
            ]
        }
    )
    async with sandbox.create_sandbox(network_policy=policy) as box:
        result = await box.run_process(
            "curl", ["--fail", "https://api.github.com/repos/acme/api"],
            capture_output=True, check=True,
        )
        return result.stdout
```

Call `await read_repository()` from your async application. The request asks GitHub for repository-scoped read access within the installation's permissions. The sandbox is stopped and destroyed when the context exits.

Header injection protects the credential from direct inspection by the sandboxed process. Code in the sandbox can still use the access granted by the policy, so keep both the allowed destination and provider permissions narrow.

### Authorize a CLI or background job

For a CLI without a browser callback, use device authorization so the user can approve in a browser. This helper requires Connect credentials and the user ID returned by your app's authentication flow.

```python
import asyncio

from vercel import connect
from vercel.connect import (
    AuthorizationPendingError,
    ConnectOptions,
    ConnectUserTokenSubject,
)

from integrations import LINEAR


async def authorize_linear_cli(user_id: str) -> str:
    subject = ConnectUserTokenSubject(id=user_id)
    authorization = await connect.start_authorization(
        LINEAR, subject=subject, device_code=True
    )
    print(f"Enter {authorization.device_code} at {authorization.url}")
    interval = 5
    while True:
        await asyncio.sleep(interval)
        try:
            return await connect.get_token(
                LINEAR, subject=subject, options=ConnectOptions(force_refresh=True)
            )
        except AuthorizationPendingError as exc:
            if exc.code == "slow_down":
                interval += 5
```

The helper returns the token after approval. `force_refresh=True` bypasses the local cache, and a `slow_down` response increases the polling interval. `AuthorizationDeniedError` and `AuthorizationExpiredError` propagate to the caller so it can stop or offer a new authorization attempt.

For scripts that only need to run synchronously, every function is also available from `vercel.connect.sync` without `await`.

### Use tokens inside durable workflows

In a [Python Workflow SDK](https://workflow-sdk.dev/docs/getting-started/python) workflow, request and use the provider token inside the same step. Step inputs and outputs are saved in the workflow event log, so return the provider operation's non-secret result rather than the credential.

Requesting the token inside the step also avoids carrying an expiring credential across a workflow pause or retry.

### More options worth knowing

- Pass `installation_id` when a connector has several installations and you need to select one workspace. Single-installation apps can omit it.
  
- Use `get_connector_metadata()` to read public connector configuration, such as a Snowflake account identifier in `.vendor`.
  
- Request supported limits through `scopes`, `resources`, and `authorization_details`. Resource indicators and authorization details are SDK-only; their effect depends on the provider.
  

### Other integration paths

Use a [Vercel Marketplace integration](https://vercel.com/marketplace) to provision a database or another project resource and manage its environment variables.

If the service has no preset connector, configure a custom OAuth or API-key connector in Connect. Supply the provider credentials during setup and adapt your token request to the subject types and permissions it supports.

## Next steps

- Read the [Python SDK reference](https://vercel.com/docs/connect/python-sdk-reference) for subjects, authorization details, caching options, and error classes
  
- Review [Tokens](https://vercel.com/docs/connect/concepts/tokens) and [Triggers](https://vercel.com/docs/connect/concepts/triggers) for provider access and inbound events
  
- Find connector commands in the [`vercel connect`](https://vercel.com/docs/cli/connect) [CLI reference](https://vercel.com/docs/cli/connect)
  
- Read [The Complete Guide to Vercel Connect](https://vercel.com/kb/guide/vercel-connect) for ownership models, observability, and pricing

## Related Documentation

- [Vercel Connect](https://vercel.com/docs/connect)

## More Vercel Connect guides

- [Build AI agents with AI Gateway and AI SDK](/kb/guide/ai-gateway-and-ai-sdk): Build AI agents on Vercel with AI Gateway and AI SDK, then make them reliable, capable, and durable with Sandbox, Chat SDK, Vercel Connect, and Workflow.
- [Build a software factory with eve](/kb/guide/eve-software-factory): Foreman, a software factory built on eve. It turns GitHub and Linear work items into reviewed draft pull requests through four agent stations, triaging issues and fixing its own red CI along the way.
- [Manage Vercel projects with a software factory](/kb/guide/software-factory-vercel-mcp): Add Vercel's hosted MCP to the eve software factory template so Foreman can read build logs, runtime errors, and deployment history for the app your target repository deploys, brokered at app scope through Vercel Connect.