Python SDK
Calling APIs
Credential-injected provider calls — request(), proxy_request(), boto3_client(), and scopes catalog.
Every credential-injection path lives on this page. The SDK fetches the token, injects it into the outbound request, calls the provider, logs the call for audit, and returns the response. The token is never returned to application code.
# Direct provider call, SDK process executesawait app.request(method, url, *, grant_id=..., ...)
# Backend-mediated execution (HITL approval, server-side isolation)await app.proxy_request(method, url, *, grant_id=..., ...)
# AWS SigV4 — real boto3 client routed through request()s3 = await app.boto3_client("s3", grant_id=...)
# Scope catalog discoverycatalog = await app.scopes.list()Picking a calling pattern
Section titled “Picking a calling pattern”| Pattern | Token leaves backend? | HITL approval? | When to use |
|---|---|---|---|
request() | Yes — SDK process holds it briefly | No (raises on HITL grants) | Default. Direct provider call, lowest latency. |
proxy_request() | No — backend executes | Yes | HITL grants, MCP/serverless without long-lived connections, compliance requires zero-egress. |
boto3_client() | Yes — SDK signs SigV4 | No | AWS APIs via real boto3. Requires [aws] extra. |
For scopes.list() see the catalog section below.
request()
Section titled “request()”The single entry point for calling a provider API with an Alter-managed credential.
async def request( method: HttpMethod | str, url: str, *, grant_id: str | None = None, provider: str | None = None, account: str | None = None, label: str | None = None, json: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None, query_params: dict[str, Any] | None = None, path_params: dict[str, str] | None = None, reason: str | None = None, context: dict[str, str] | None = None, body: bytes | None = None, caller: str | None = None, user_token: str | None = None, app_user_id: str | None = None, scope: list[str] | None = None, constraints: dict[str, Any] | None = None,) -> httpx.Response| Parameter | Type | Default | Description |
|---|---|---|---|
method | HttpMethod | str | — | HTTP method. The HttpMethod enum is recommended for IDE autocomplete. |
url | str | — | Full provider URL. Must start with http:// or https://. Supports {name} placeholders resolved via path_params. |
grant_id | str | None | None | Direct-mode grant identifier. Mutually exclusive with provider. |
provider | str | None | None | Identity-mode key. OAuth: the provider id (e.g. "google"). Managed secret: the per-secret slug (e.g. "stripe-production"), unique per app. Requires user_token_getter on the client or user_token= per call. Mutually exclusive with grant_id. |
account | str | None | None | Disambiguator for identity mode when the resolved user has multiple grants on the same provider (see AmbiguousGrantError). |
label | str | None | None | Sibling-grant disambiguator for identity mode — selects one of several same-provider grants (the resolution key is provider + label). Only valid alongside provider. |
json | dict | None | None | JSON-serialized request body. Mutually exclusive with body. |
body | bytes | None | None | Raw bytes request body. Mutually exclusive with json. |
extra_headers | dict[str, str] | None | None | Extra request headers. Authorization is injected by the SDK and must not be set here. |
query_params | dict[str, Any] | None | None | Query string parameters. |
path_params | dict[str, str] | None | None | Values substituted into {name} placeholders in url. URL-encoded. |
reason | str | None | None | Audit reason recorded with the call. |
context | dict[str, str] | None | None | Audit-correlation context. Capped at 4096 bytes, 20 keys, 64-char keys, 512-char values. Falls back to the ambient context from Agent.trace() / @alter_tool / @alter.tool() when omitted. |
caller | str | None | None | Per-call override of the client’s caller identifier. |
user_token | str | None | None | Per-call user JWT for identity mode. Wins over user_token_getter. |
app_user_id | str | None | None | Per-call user identifier for agent-delegation disambiguation. |
scope | list[str] | None | None | |
constraints | dict[str, Any] | None | None |
Returns: httpx.Response. Inspect response.status_code, response.json(), response.text, etc.
Raises:
AlterSDKError— missing bothgrant_idandprovider, supplying both, supplying bothjsonandbody, client closed, URL with disallowed scheme.AlterValueError— badcontextshape, malformeduser_token/app_user_id, reservedscope/constraintsset.GrantNotFoundError,GrantExpiredError,GrantRevokedError,GrantDeletedError,CredentialRevokedError— grant-state failures (see Errors).AmbiguousGrantError— identity-mode resolution matched multiple grants. Inspecte.account_identifiersand retry withaccount=.NoDelegatedGrantError— agent caller has no access path to the provider.PolicyViolationError,InsufficientScopeError,ScopeReauthRequiredError,TokenRefreshInProgressError— policy / scope / refresh failures.ProviderAPIError— non-2xx provider response (raised only when the SDK can map it to a typed error).NetworkError,TimeoutError— connectivity failures.
Direct grant mode
Section titled “Direct grant mode”Pass grant_id= when the application has the grant identifier in hand (stored after a Connect flow, returned by list_grants(), etc.).
from alter_sdk import App, HttpMethod
app = App(api_key="alter_rk_…")
resp = await app.request( HttpMethod.GET, "https://www.googleapis.com/calendar/v3/calendars/primary/events", grant_id="11111111-2222-3333-4444-555555555555", query_params={"maxResults": 10},)events = resp.json()Identity mode
Section titled “Identity mode”Pass provider= when the caller is acting on behalf of a known user. The SDK resolves the user’s JWT (from user_token_getter or user_token=) and looks up the grant.
from alter_sdk import App
def jwt_for_current_user() -> str: return request_state.user_jwt
app = App( api_key="alter_rk_…", user_token_getter=jwt_for_current_user,)
resp = await app.request( "GET", "https://api.github.com/user/repos", provider="github",)from alter_sdk import App
app = App(api_key="alter_rk_…")
resp = await app.request( "GET", "https://slack.com/api/conversations.list", provider="slack", user_token=user_jwt,)from alter_sdk import App, HttpMethod
app = App(api_key="alter_key_app_…")
# `provider` is the managed secret's slug (e.g. "stripe-production"), not "stripe".resp = await app.request( HttpMethod.POST, "https://api.stripe.com/v1/charges", provider="stripe-production", user_token=user_jwt, json={"amount": 1000, "currency": "usd"},)Disambiguating with account
Section titled “Disambiguating with account”When identity mode resolves to multiple grants on the same provider, the backend returns 409 and the SDK raises AmbiguousGrantError. Retry with account=:
from alter_sdk import AmbiguousGrantError
try: resp = await app.request("GET", url, provider="google")except AmbiguousGrantError as e: chosen = e.account_identifiers[0] resp = await app.request("GET", url, provider="google", account=chosen)URL templates with path_params
Section titled “URL templates with path_params”resp = await app.request( "GET", "https://api.github.com/repos/{owner}/{repo}/issues/{number}", grant_id=grant_id, path_params={"owner": "anthropic", "repo": "claude-code", "number": "42"},)Values are URL-encoded before substitution.
Audit context
Section titled “Audit context”context is propagated to the audit row for every call:
resp = await app.request( "POST", "https://api.openai.com/v1/chat/completions", grant_id=grant_id, json=payload, reason="user_initiated_chat", context={"thread_id": "thr_abc", "tool": "summarize"},)When called inside an Agent.trace() block or from inside a @alter_tool / @alter.tool() body, the ambient context is used automatically if context= is not passed.
proxy_request()
Section titled “proxy_request()”proxy_request() sends the full request to Alter for server-side execution. The backend resolves the credential, calls the provider, and either returns the response synchronously or returns a PendingApproval if the grant requires human-in-the-loop approval.
Use it when:
- The grant has an HITL approval policy attached.
- The workload cannot hold the provider connection open (serverless cold starts, MCP tool calls).
- Audit / compliance requires the credential to never leave the backend perimeter.
For the common case of “I have a credential, call this URL”, use request() instead.
async def proxy_request( method: HttpMethod | str, url: str, *, grant_id: str | None = None, provider: str | None = None, account: str | None = None, label: str | None = None, json_body: dict | list | str | bool | float | None = None, headers: dict[str, str] | None = None, query_params: dict[str, Any] | None = None, reason: str | None = None,) -> ApprovalResult | PendingApproval| Parameter | Type | Default | Description |
|---|---|---|---|
method | HttpMethod | str | — | HTTP method. |
url | str | — | Full provider URL. |
grant_id | str | None | None | Direct-mode grant identifier. Exactly one of grant_id or provider must be supplied — proxy_request() shares the same resolution contract as request(): one match resolves, zero raises a not-found error, several raise AmbiguousGrantError. |
provider | str | None | None | Identity-mode key (e.g. "google", or a managed secret’s per-secret slug). Mutually exclusive with grant_id. |
account | str | None | None | Account disambiguator for provider resolution. |
label | str | None | None | Sibling-grant disambiguator for provider resolution (the resolution key is provider + label). Only valid alongside provider. |
json_body | dict | list | str | bool | float | None | None | JSON-serializable request body. |
headers | dict[str, str] | None | None | Non-auth request headers. Authorization, Cookie, x-api-key, x-amz-security-token are rejected by the backend (422) — the backend injects the credential at execution time. |
query_params | dict[str, Any] | None | None | Query string parameters. |
reason | str | None | None | Audit reason. |
Returns: ApprovalResult for synchronous (non-HITL) execution, or PendingApproval when the grant requires approval (HTTP 202).
Proxy-mode limits
Section titled “Proxy-mode limits”- The provider response body is buffered by the backend, base64-encoded, and may be truncated when very large. Proxy mode is not intended for downloads, Server-Sent Events, WebSockets, or long-lived streaming responses.
- Auth and cookie-related provider response headers are stripped before the SDK receives the response. Flows that depend on
Set-Cookie,WWW-Authenticate, or returned authorization headers may needrequest()or the provider client directly. - Managed-secret proxy calls require destination hosts to be configured on the secret. If the allowlist is missing or does not match the requested host, the call is blocked before the credential is injected.
proxy_request()does not automatically retry the initial call. Add application-level retry/backoff for network failures and timeouts. For HITL grants, retry carefully because submitting the same call again can create another pending approval.- The CLI passthrough command uses retrieve mode, not proxy mode. It is useful for testing
request(), but it does not reproduce proxy-only behavior such as HITL execution, destination-host allowlist enforcement, or response truncation.
Raises:
AlterValueError— neithergrant_idnorprovidersupplied (or both), an identifier value is an empty string,account/labelsupplied withoutprovider, payload not JSON-serializable, header validation failure, forbidden auth header.BackendErrorand subclasses — backend-side failure or grant-state error.NetworkError,TimeoutError— connectivity failures.
Sync execution
Section titled “Sync execution”When the grant has no approval requirement, the backend executes the call and returns the response:
from alter_sdk import App, HttpMethodfrom alter_sdk.models import ApprovalResult
app = App(api_key="alter_rk_…")
result = await app.proxy_request( HttpMethod.POST, "https://api.stripe.com/v1/charges", grant_id=grant_id, json_body={"amount": 2000, "currency": "usd", "source": "tok_visa"},)assert isinstance(result, ApprovalResult)print(result.status_code, result.body_json())HITL approval flow
Section titled “HITL approval flow”When the grant has requires_approval configured, the backend returns 202 and a PendingApproval. Pass approval_id to await_approval() to block until the approver decides.
from alter_sdk.models import ApprovalResult, PendingApproval
result = await app.proxy_request( "POST", "https://api.stripe.com/v1/refunds", grant_id=grant_id, json_body={"charge": "ch_abc123"}, reason="customer_service_refund",)
if isinstance(result, PendingApproval): notify_approver(result.approval_url) final = await app.await_approval( str(result.approval_id), timeout=600.0, ) assert isinstance(final, ApprovalResult) print(final.body_json())Reading the response body
Section titled “Reading the response body”ApprovalResult stores the body base64-encoded. Use the helpers:
result.body_bytes() # raw bytesresult.body_text() # UTF-8 decoderesult.body_text("latin-1")result.body_json() # parse JSONresult.status_code # HTTP status from the providerresult.headers # response headers (dict)See ApprovalResult for the full model.
Forbidden headers
Section titled “Forbidden headers”The backend rejects requests that try to inject credentials in headers:
AuthorizationCookiex-api-keyx-amz-security-token
The backend injects the correct credential at execution time based on the grant.
boto3_client()
Section titled “boto3_client()”boto3_client() returns a real boto3 service client whose every request flows through the SDK’s request pipeline: credential retrieval, SigV4 signing, policy enforcement, and audit logging. The AWS access keys are managed by Alter and never reach application code.
Requires the aws extra:
pip install 'alter-sdk[aws]'async def boto3_client( service_name: str, *, grant_id: str, region_name: str | None = None, timeout: float | None = None, reason: str | None = None, context: dict[str, str] | None = None,) -> Any| Parameter | Type | Default | Description |
|---|---|---|---|
service_name | str | — | AWS service name ("s3", "dynamodb", "sqs", …). Positional. |
grant_id | str | — | Alter grant ID for AWS credentials. Keyword-only. |
region_name | str | None | None | AWS region. Falls back to "us-east-1" when omitted. |
timeout | float | None | client timeout + 5 s buffer | Per-call wall-clock timeout covering token retrieval and the HTTP request. |
reason | str | None | None | Audit reason applied to every call from this client. |
context | dict[str, str] | None | None | Audit-correlation context applied to every call. |
Returns: a boto3 service client. Every call routes through request() for credential injection.
Raises: AlterSDKError if boto3 is not installed or the SDK client is closed.
boto3’s API is synchronous. Wrap calls in asyncio.to_thread() from async code:
import asynciofrom alter_sdk import App
app = App(api_key="alter_rk_…")
s3 = await app.boto3_client("s3", grant_id=aws_grant_id, region_name="us-west-2")
response = await asyncio.to_thread( s3.list_objects_v2, Bucket="my-bucket", MaxKeys=100,)for obj in response.get("Contents", []): print(obj["Key"], obj["Size"])With Agent
Section titled “With Agent”from alter_sdk import Agent
agent = Agent(api_key="alter_ak_…")
ddb = await agent.boto3_client( "dynamodb", grant_id=ddb_grant_id, region_name="us-east-1", reason="agent_query",)
result = await asyncio.to_thread( ddb.get_item, TableName="orders", Key={"order_id": {"S": "ord_123"}},)Lifecycle and large payloads
Section titled “Lifecycle and large payloads”scopes.list()
Section titled “scopes.list()”The scopes namespace exposes the backend’s scope-catalog discovery endpoint. The catalog is static metadata for a given backend version — use it to introspect what scopes exist before calling keys.derive(), or to render a scope picker in a custom dashboard.
Both App and Agent expose scopes.
async def list() -> ScopeCatalogReturns: ScopeCatalog — scope_version, per-resource verb lists, action verb list, deprecated scopes.
Raises: BackendError on backend reachability or response-shape failure.
from alter_sdk import App
app = App(api_key="alter_rk_…")
catalog = await app.scopes.list()print("scope_version:", catalog.scope_version)for resource, info in catalog.resources.items(): print(f" {resource}: {info.verbs}")print("action verbs:", catalog.action_verbs)print("deprecated:", catalog.deprecated)The catalog is not cached by the SDK — callers are expected to call list() on demand. No scope is required to read the catalog.
OpenTelemetry trace propagation
Section titled “OpenTelemetry trace propagation”When the application runs an OpenTelemetry SDK with an active span, every call the SDK makes to Alter automatically carries the standard W3C traceparent header — request(), proxy_request(), approval polling, and the audit-reporting calls included. Alter uses it as the trace context for that request’s audit events, and any spans the organization streams to its own OTLP collector join the application’s traces instead of starting disconnected ones.
No configuration is required, and OpenTelemetry is never installed by the SDK itself — it uses whatever OpenTelemetry the application installed. Without OpenTelemetry (or without an active span) the header is simply omitted and nothing changes. The integration is best-effort by design: it can never fail a vault call (the very first call pays a one-time, in-process lookup of the optional module). The optional alter-sdk[otel] extra records the supported opentelemetry-api version range in the application’s dependency tree.
from opentelemetry import trace
tracer = trace.get_tracer("the-application")
# Inside an async function; `app` from the quick start.with tracer.start_as_current_span("handle-user-request"): # This call's audit events share the surrounding trace's ids. response = await app.request(HttpMethod.GET, url, grant_id=grant_id)Only trace/span identifiers and a sampling flag travel in the header — no payloads, no user identifiers.