Skip to content

Reference

Calling APIs

Credential-injected provider calls, scope discovery, and user-defined trace spans.

This page covers the SDK’s provider-call primitives. request() injects the provider credential in the SDK process; proxy_request() keeps injection and execution in Alter’s backend. Both paths audit the call and return the provider response without returning the credential 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=..., ...)
# Scope catalog discovery
catalog = await app.scopes.list()
# User-defined trace spans
emit_result = await app.spans.emit([span])
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.

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

The direct-execution entry point for calling a provider API with an Alter-managed credential. Use proxy_request() for backend-mediated execution.

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] | list[Any] | str | bool | int | float | 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,
) -> httpx.Response
ParameterTypeDefaultDescription
methodHttpMethod | strHTTP method. The HttpMethod enum is recommended for IDE autocomplete.
urlstrFull provider URL. HTTPS is required; HTTP is accepted only for localhost, 127.0.0.1, or [::1] development endpoints. Supports {name} placeholders resolved via path_params.
grant_idstr | NoneNoneDirect-mode grant identifier. Mutually exclusive with provider.
providerstr | NoneNoneIdentity-mode key. OAuth: a provider id (e.g. "provider-id"). Managed secret: the per-secret slug (e.g. "service-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 | list | str | bool | int | float | NoneNoneJSON-serialized request body. Mutually exclusive with body. Numbers must be finite; NaN and positive/negative infinity raise AlterValueError before any request.
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.

To narrow scopes or attach a request rule to a call, mint a constrained sub-client with with_constraints() rather than passing per-call policy arguments.

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, app_user_id or label supplied without provider, an unresolved {name} placeholder left in url after path_params, or a json value that is not strict JSON (including non-finite numbers, circular references, or custom objects).
  • 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.
  • AgentNotFoundError — the configured caller identifier (client-level or per-call) is a UUID naming no active managed agent in this app; details["error"] == "caller_agent_unresolved". Fix the asserted caller identity.
  • 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.

Provider transport failures still emit an API-call audit event before the typed error is raised; closing the root client ensures that pending work completes before exit.

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://api.provider.example/v1/resources",
grant_id="11111111-2222-3333-4444-555555555555",
query_params={"limit": 10},
)
resources = 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.provider.example/v1/resources",
provider="provider-id",
)
from alter_sdk import App
app = App(api_key="alter_rk_…")
resp = await app.request(
"GET",
"https://api.provider.example/v1/resources",
provider="provider-id",
user_token=user_jwt,
)
from alter_sdk import App, HttpMethod
app = App(api_key="alter_key_app_…")
# `provider` is the managed secret's slug, not its provider template name.
resp = await app.request(
HttpMethod.POST,
"https://api.provider.example/v1/actions",
provider="service-production",
user_token=user_jwt,
json={"resource_id": "resource_123"},
)

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="provider-id")
except AmbiguousGrantError as e:
chosen = e.account_identifiers[0]
resp = await app.request(
"GET",
url,
provider="provider-id",
account=chosen,
)
resp = await app.request(
"GET",
"https://api.provider.example/v1/projects/{project}/items/{item_id}",
grant_id=grant_id,
path_params={"project": "example", "item_id": "42"},
)

Values are URL-encoded before substitution.

context is propagated to the audit row for every call:

resp = await app.request(
"POST",
"https://api.provider.example/v1/actions",
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: dict | list | str | bool | int | float | 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,
caller: str | None = None,
user_token: str | None = None,
app_user_id: str | None = None,
) -> ApprovalResult | PendingApproval

proxy_request() takes the same arguments as request(), by the same names. The only differences are inherent to the execution model: there is no raw-bytes body argument (the backend executes the call, so the body must be a JSON value — pass it as json), auth headers are rejected, and the return type is an ApprovalResult / PendingApproval instead of a live response.

ParameterTypeDefaultDescription
methodHttpMethod | strHTTP method.
urlstrFull provider URL. Supports {name} placeholders resolved via path_params.
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. "provider-id", or a managed secret’s per-secret slug). Resolves the user via user_token (or the client’s user_token_getter); for an agent caller, disambiguate delegations with app_user_id. 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.
jsondict | list | str | bool | int | float | NoneNoneJSON-serializable request body (same argument name as request()). None omits the body. Numbers must be finite.
extra_headersdict[str, str] | NoneNoneNon-auth request headers (same argument name as request()). Authorization, Cookie, x-api-key, x-amz-security-token are rejected — the backend injects the credential at execution time.
query_paramsdict[str, Any] | NoneNoneQuery string parameters.
path_paramsdict[str, str] | NoneNoneValues substituted into {name} placeholders in url. URL-encoded.
reasonstr | NoneNoneAudit reason.
contextdict[str, str] | NoneNoneAudit-correlation context (same shape and limits as request()). 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. Valid in both addressing modes.
app_user_idstr | NoneNonePer-call user identifier for agent-delegation disambiguation. Only valid with provider resolution.

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/app_user_id supplied without provider, an unresolved {name} placeholder left in url after path_params, 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.provider.example/v1/actions",
grant_id=grant_id,
json={"resource_id": "resource_123"},
)
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.provider.example/v1/restricted-actions",
grant_id=grant_id,
json={"resource_id": "resource_abc"},
reason="operator_requested_action",
)
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)
result.duration_ms # provider round-trip milliseconds, or None

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.

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.

The spans namespace emits caller-defined spans into the same trace waterfall as credential operations. It is available on App, Agent, and constrained sibling clients.

from datetime import UTC, datetime
from alter_sdk import UserSpan
result = await app.spans.emit(
[
UserSpan(
trace_id="trace-123",
name="plan",
start_time=datetime.now(UTC),
attributes={"stage": "planning"},
),
],
)
print(result.accepted)
async def emit(spans: Sequence[UserSpan]) -> EmitSpansResult

Each batch must contain 1–50 UserSpan objects. trace_id, name, and start_time are required. span_id, parent_span_id, end_time, and attributes: dict[str, str] are optional. Supply a stable span_id to make retries idempotent; duplicate (trace_id, span_id) pairs are absorbed and are not included in EmitSpansResult.accepted.

Raises: AlterValueError for an empty or oversized batch or a non-UserSpan item, InsufficientScopeError when the key lacks spans:emit, and BackendError for backend or response-shape failures.

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.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.