Skip to content

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 executes
await 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 discovery
catalog = await app.scopes.list()
PatternToken leaves backend?HITL approval?When to use
request()Yes — SDK process holds it brieflyNo (raises on HITL grants)Default. Direct provider call, lowest latency.
proxy_request()No — backend executesYesHITL grants, MCP/serverless without long-lived connections, compliance requires zero-egress.
boto3_client()Yes — SDK signs SigV4NoAWS APIs via real boto3. Requires [aws] extra.

For scopes.list() see the catalog section below.

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
ParameterTypeDefaultDescription
methodHttpMethod | strHTTP method. The HttpMethod enum is recommended for IDE autocomplete.
urlstrFull provider URL. Must start with http:// or https://. Supports {name} placeholders resolved via path_params.
grant_idstr | NoneNoneDirect-mode grant identifier. Mutually exclusive with provider.
providerstr | NoneNoneIdentity-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.
accountstr | NoneNoneDisambiguator for identity mode when the resolved user has multiple grants on the same provider (see AmbiguousGrantError).
labelstr | NoneNoneSibling-grant disambiguator for identity mode — selects one of several same-provider grants (the resolution key is provider + label). Only valid alongside provider.
jsondict | NoneNoneJSON-serialized request body. Mutually exclusive with body.
bodybytes | NoneNoneRaw bytes request body. Mutually exclusive with json.
extra_headersdict[str, str] | NoneNoneExtra request headers. Authorization is injected by the SDK and must not be set here.
query_paramsdict[str, Any] | NoneNoneQuery string parameters.
path_paramsdict[str, str] | NoneNoneValues substituted into {name} placeholders in url. URL-encoded.
reasonstr | NoneNoneAudit reason recorded with the call.
contextdict[str, str] | NoneNoneAudit-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.
callerstr | NoneNonePer-call override of the client’s caller identifier.
user_tokenstr | NoneNonePer-call user JWT for identity mode. Wins over user_token_getter.
app_user_idstr | NoneNonePer-call user identifier for agent-delegation disambiguation.
scopelist[str] | NoneNone
constraintsdict[str, Any] | NoneNone

Returns: httpx.Response. Inspect response.status_code, response.json(), response.text, etc.

Raises:

  • AlterSDKError — missing both grant_id and provider, supplying both, supplying both json and body, client closed, URL with disallowed scheme.
  • AlterValueError — bad context shape, malformed user_token / app_user_id, reserved scope / constraints set.
  • GrantNotFoundError, GrantExpiredError, GrantRevokedError, GrantDeletedError, CredentialRevokedError — grant-state failures (see Errors).
  • AmbiguousGrantError — identity-mode resolution matched multiple grants. Inspect e.account_identifiers and retry with account=.
  • 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.

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()

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"},
)

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)
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.

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() 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
ParameterTypeDefaultDescription
methodHttpMethod | strHTTP method.
urlstrFull provider URL.
grant_idstr | NoneNoneDirect-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.
providerstr | NoneNoneIdentity-mode key (e.g. "google", or a managed secret’s per-secret slug). Mutually exclusive with grant_id.
accountstr | NoneNoneAccount disambiguator for provider resolution.
labelstr | NoneNoneSibling-grant disambiguator for provider resolution (the resolution key is provider + label). Only valid alongside provider.
json_bodydict | list | str | bool | float | NoneNoneJSON-serializable request body.
headersdict[str, str] | NoneNoneNon-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_paramsdict[str, Any] | NoneNoneQuery string parameters.
reasonstr | NoneNoneAudit reason.

Returns: ApprovalResult for synchronous (non-HITL) execution, or PendingApproval when the grant requires approval (HTTP 202).

  • 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 need request() 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 — neither grant_id nor provider supplied (or both), an identifier value is an empty string, account/label supplied without provider, payload not JSON-serializable, header validation failure, forbidden auth header.
  • BackendError and subclasses — backend-side failure or grant-state error.
  • NetworkError, TimeoutError — connectivity failures.

When the grant has no approval requirement, the backend executes the call and returns the response:

from alter_sdk import App, HttpMethod
from 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())

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())

ApprovalResult stores the body base64-encoded. Use the helpers:

result.body_bytes() # raw bytes
result.body_text() # UTF-8 decode
result.body_text("latin-1")
result.body_json() # parse JSON
result.status_code # HTTP status from the provider
result.headers # response headers (dict)

See ApprovalResult for the full model.

The backend rejects requests that try to inject credentials in headers:

  • Authorization
  • Cookie
  • x-api-key
  • x-amz-security-token

The backend injects the correct credential at execution time based on the grant.


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:

Terminal window
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
ParameterTypeDefaultDescription
service_namestrAWS service name ("s3", "dynamodb", "sqs", …). Positional.
grant_idstrAlter grant ID for AWS credentials. Keyword-only.
region_namestr | NoneNoneAWS region. Falls back to "us-east-1" when omitted.
timeoutfloat | Noneclient timeout + 5 s bufferPer-call wall-clock timeout covering token retrieval and the HTTP request.
reasonstr | NoneNoneAudit reason applied to every call from this client.
contextdict[str, str] | NoneNoneAudit-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 asyncio
from 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"])
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"}},
)

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() -> ScopeCatalog

Returns: ScopeCatalogscope_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.

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.