Skip to main content

spaps (Python client)

Python SDK for SPAPS-compatible APIs. The distribution name is spaps; the import path is spaps_client.

Examples in this README use placeholders such as user@example.com, admin@example.com, spaps_test_key, and https://api.example.test. Replace them with values from your own deployment.

Install

pip install spaps

This package targets Python 3.9+.

When It Fits

Need Package gives you
Sync and async clients SpapsClient and AsyncSpapsClient with a similar surface
Typed responses Pydantic models for auth, sessions, payments, entitlements, webhooks, and more
HTTP control points Retry config, logging hooks, injected httpx clients, and custom token storage
Narrow integrations Standalone helpers for device flow, webhooks, users, email, entitlements, marketing, and permission checks

Quick Start

from spaps_client import MfaRequiredChallenge, SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    login = client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    if isinstance(login, MfaRequiredChallenge):
        tokens = client.auth.mfa_verify(
            challenge_id=login.challenge_id,
            challenge=login.challenge,
            code="123456",
        )
    else:
        tokens = login

    bootstrap = client.auth.get_session_context()
    session = client.sessions.get_current_session()
    products = client.payments.list_products(
        category="subscription",
        active=True,
        limit=5,
    )

    print(tokens.user.email)
    print(bootstrap.tier)
    print(session.session_id)
    print(products.total)
finally:
    client.close()

Async Example

import asyncio

from spaps_client import AsyncSpapsClient


async def main() -> None:
    client = AsyncSpapsClient(
        base_url="http://localhost:3301",
        api_key="spaps_test_key",
    )
    try:
        await client.auth.sign_in_with_password(
            email="user@example.com",
            password="correct-horse-battery-staple",
        )
        sessions = await client.sessions.list_sessions()
        print(sessions.total)
    finally:
        await client.aclose()


asyncio.run(main())

Sync and async auth calls raise AuthError for HTTP failures, including malformed upstream error bodies. Use status_code, error_code, and request_id for handling. Valid structured responses retain provider messages and details; malformed and plain-text server responses use a generic message.

Sync and async sessions.list_sessions() use limit and zero-based offset for pagination. The server defaults to 50 sessions per page. Session calls raise SessionError for malformed HTTP responses, including non-object 2xx bodies. Valid structured errors retain their code and message; malformed or plain-text server errors use a generic message.

Sync and async whitelist calls raise WhitelistError for malformed HTTP responses, including non-object 2xx bodies. Valid structured errors keep their provider code and message; malformed or plain-text server errors use a generic message.

Modern Auth Methods

The auth client exposes the active SPAPS auth routes in both sync and async forms:

from spaps_client import MfaRequiredChallenge, SpapsClient

client = SpapsClient(base_url="http://localhost:3301", api_key="spaps_test_key")

methods = client.auth.list_methods()
print([item.method for item in methods.methods if item.enabled])

oidc = client.auth.oidc_nonce()
login = client.auth.oidc_sign_in(
    provider="google",
    id_token="provider-id-token",
    challenge_id=oidc.challenge_id,
)

if isinstance(login, MfaRequiredChallenge):
    login = client.auth.mfa_verify(
        challenge_id=login.challenge_id,
        challenge=login.challenge,
        recovery_code="recovery-code",
    )

TOTP management and passkey registration are authenticated operations and use stored tokens unless you pass access_token explicitly:

enrollment = client.auth.mfa_totp_enroll()
activation = client.auth.mfa_totp_activate(code="123456")
options = client.auth.webauthn_register_options(origin="https://app.example.com")
credential = client.auth.webauthn_register_verify(
    challenge_id=options.challenge_id,
    credential={"id": "browser-produced-credential"},
    origin="https://app.example.com",
)

The sync and async auth clients also cover passkey-first bootstrap, credential listing/labeling/revocation, application passkey profiles, action-bound step-up, user recovery, and administrator recovery preparation/execution. Pass origin on ceremonies so SPAPS can validate the browser origin. Public ceremonies send only X-API-Key; lifecycle, profile, step-up, and administrator operations also require a bearer token, using an explicit access_token before stored credentials.

Python coordinates the server half of each WebAuthn ceremony. It returns typed registration or assertion options and accepts the credential JSON produced by a browser or another WebAuthn-capable user agent. It does not call navigator.credentials, emulate an authenticator, perform CTAP, or sign challenges. Recovery registration rotates the recovery grant; it does not issue login tokens until the new credential completes the recovery assertion.

Core Surface

High-Level Clients

Client Best for
SpapsClient Sync auth, sessions, payments, usage, access decisions, capability graph inspection, whitelist, secure messages, issue reporting, marketing, metrics, app links, dayrate, skill evals, token gates, and support telemetry
AsyncSpapsClient Async auth, sessions, payments, usage, whitelist, secure messages, issue reporting, metrics, entitlements, app links, dayrate, skill evals, token gates, and users

Standalone Helpers

Helper Purpose
EntitlementsClient / AsyncEntitlementsClient Resource entitlements, purchase history, manual grants/revokes, and project grants
TokenGatesClient / AsyncTokenGatesClient Public enabled-gate list and user JWT verify; UNVERIFIABLE is unknown, not no
UsageClient / AsyncUsageClient Secret-key usage authorization and immutable usage recording
CapabilityClient Access decisions, action preparation, decision traces, graph inspection, and contract discovery
EmailClient / AsyncEmailClient Template lookup, preview, and send flows
UsersClient / AsyncUsersClient Batch user/email lookups, secret-key membership decisions, and app membership administration
MarketingClient Browser-safe marketing event emission and server-side experiment results
AppLinksClient / AsyncAppLinksClient Authenticated short-link creation/update and public link resolution
DayrateClient / AsyncDayrateClient Availability, booking, cancellation, x402 booking holds, and admin booking reads
DeviceFlowClient Device-code login workflows
PermissionChecker Role and admin convenience checks
verify_spaps_webhook Signature verification for incoming SPAPS webhooks

Public app-link lookups omit owner_user_id; AppLink.owner_user_id is None for those results and is present on authenticated create/update results. Sync and async app-link calls raise AppLinkError for non-JSON or non-object HTTP responses; malformed server errors use a generic message and retain the HTTP status.

Sync and async dayrate.cancel_booking() require the booking owner's access token, stored on the client or passed as access_token_override. The legacy user_id and email arguments remain accepted but are omitted from the request: the server establishes ownership from the bearer token.

Device-code login

DeviceFlowClient is an async public-client flow. Pass the SPAPS application slug as client_id, or set SPAPS_CLI_CLIENT_ID. The authorize and token requests are keyless: they send client_id, the RFC 8628 device-code grant type, and the current protocol fields, but never X-API-Key or a bearer token.

import asyncio

from spaps_client import DeviceFlowClient


async def device_login() -> None:
    client = DeviceFlowClient(
        base_url="https://api.example.test",
        client_id="my-app",
    )
    try:
        tokens = await client.login()
        print(tokens.expires_in)
    finally:
        await client.aclose()


asyncio.run(device_login())

login() displays the server-provided verification_uri_complete when available, so the application-specific page can prefill the user code. The older api_key= constructor keyword remains accepted for migration safety but is ignored by device authorize/token calls; it cannot substitute for client_id.

Device-flow HTTP failures raise DeviceFlowError even when an upstream error body is malformed. Handle status_code and error_code; server-side failures use a generic exception message instead of copying upstream response text.

Configuration

Constructor values override package defaults.

from spaps_client import SpapsClient, RetryConfig, default_logging_hooks

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    retry_config=RetryConfig(max_attempts=4, backoff_factor=0.2),
    logging_hooks=default_logging_hooks(),
)

Common parameters:

Parameter Purpose
base_url Target API origin
api_key Application or service API key
request_timeout Per-request timeout
token_storage Custom token persistence backend
http_client Injected httpx.Client or httpx.AsyncClient
retry_config Retry and backoff policy
logging_hooks Structured request and response logging callbacks

default_logging_hooks() records method, host, status, timing, attempt, and error type. It omits URL paths and queries, URL credentials, response request IDs, and exception messages because these can contain private data. Custom logging hooks must apply their own redaction.

Requests that cannot be retried keep their streaming bodies; the SDK does not buffer them before the first send. For retryable streamed bodies, it spools up to 1 MiB in memory, then uses a temporary file and sends 64 KiB chunks on each attempt. The spool is removed after the final attempt; large retries need temporary disk space.

Common Flows

Agent Access Decisions

client.access.check_access(...) returns a typed decision for both allowed and denied outcomes. A blocked action is not an exception; inspect allowed, outcome, and next_actions. Checkout clients reject non-boolean authority flags and contradictory allowed/outcome or action-execution gates. Malformed response objects raise CapabilityError; unstructured error bodies do not become exception text.

from spaps_client import AccessDecisionRequest, CapabilityError, SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)
client.set_tokens(access_token="admin-access-token")

try:
    decision = client.access.check_access(
        AccessDecisionRequest.model_validate(
            {
                "actor": {"actor_type": "user", "actor_ref": "user_123"},
                "action": "checkout.create",
                "resource": {"resource_type": "product", "resource_ref": "dayrate"},
                "controls": {"entitlement_key": "bookme_paid"},
            }
        )
    )

    if not decision.allowed:
        print(decision.outcome, decision.next_actions)

    prepared = client.access.prepare_action(
        {
            "access": {
                "actor": {"actor_ref": "admin_123"},
                "action": "admin.delete_user",
                "resource": {"resource_type": "user", "resource_ref": "user_123"},
            },
            "include_command_templates": True,
            "environment": "production",
        }
    )
    print(prepared.status, prepared.execution.operator_gate_required)

    contract = client.contract.get_contract()
    refresh = client.graph.refresh_graph(correlation_id="local-refresh")
    nodes = client.graph.list_graph_nodes(node_type="x402_resource", q="dayrate")
    explanation = client.access.explain_decision(decision.decision_trace_id)
    print(contract.version, refresh.status, refresh.diagnostics.phase2_gate.recommendation if refresh.diagnostics.phase2_gate else None)
    print(nodes.projection.projection_status if nodes.projection else None, explanation.graph_node_keys)
except CapabilityError as exc:
    print(exc.status_code, exc.code, exc.request_id, exc.diagnostics, exc.remediations)
finally:
    client.close()

client.contract.get_contract() includes graph vocabulary fields: graph_node_types, graph_edge_types, graph_source_domains, and source_domain_notes. Use them before filtering graph nodes by stable v1 types such as wallet, api_key, role, and approver.

CapabilityError exposes both Python-native names (status_code, error_code, request_id) and parity aliases (status, code). Diagnostics and remediations preserve server-provided details fields.

operator-gated is compatibility/descriptive input when a client supplies it; it does not authorize mutation command templates. SPAPS returns mutation command templates only when the request has a server-recognized non-publishable key and an authenticated admin/operator user context. Publishable callers cannot self-attest the gate.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    client.auth.send_magic_link(email="user@example.com")
    reset_request = client.auth.request_password_reset(email="user@example.com")
    reset_confirm = client.auth.confirm_password_reset(
        token="reset-token-from-email",
        new_password="Sup3rStrong!",
    )
    print(reset_request.email, reset_request.sent_at, reset_confirm.message)
finally:
    client.close()

Password reset responses use the standard SPAPS success envelope with top-level success, data, request_id, and timestamp. The Python client returns the nested data payload: reset requests expose email and sent_at, while reset confirmation exposes the nested message. A top-level English message is not canonical and should not drive UI copy. Prefer app-owned success copy, or map future nested data.message_key/data.status values in your app.

Downstream Adoption

HTMA and future SPAPS consumers should use spaps>=0.6.2 for password reset flows and stream-aware email sends. Version 0.6.2 is available on PyPI; the passkey-complete 0.7.0 line remains a retained candidate until publication.

Release execution is intentionally separate from this handoff. Publish only the retained exact artifact from the package self-release lane. Do not use npm run publish:python-client, deploy SPAPS, or publish the package as part of a consumer-only adoption check.

Authenticated Stripe Checkout

create_checkout_session is the convenience path for authenticated checkout. Pass the server-managed Stripe price_id; the client sends the active Stripe checkout contract with one line item for that price.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_test_key",
)

try:
    client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    checkout = client.payments.create_checkout_session(
        price_id="price_123",
        mode="subscription",
        success_url="https://app.example.test/success",
        cancel_url="https://app.example.test/cancel",
        trial_period_days=14,
    )
    print(checkout.checkout_url)
finally:
    client.close()

Server-Side Usage Control

Use usage from a trusted backend with a secret SPAPS key. Browser apps should call their own backend first; that backend asks SPAPS to authorize the work, runs the work only on an allow or warning decision, then records the actual usage with a stable idempotency key.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    authorization = client.usage.authorize_usage(
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={"requests": 1, "input_tokens": 1200},
    )
    if authorization.decision == "blocked":
        message = authorization.reasons[0].message if authorization.reasons else "Usage not authorized"
        raise RuntimeError(message)

    result = run_assistant_job()

    client.usage.record_usage(
        authorization_ref=authorization.authorization_ref,
        idempotency_key=result.job_id,
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={
            "input_tokens": result.input_tokens,
            "output_tokens": result.output_tokens,
        },
        metadata={"job_id": result.job_id},
    )
finally:
    client.close()

For dashboard and policy views, use client.usage.get_features(), client.usage.get_status(...), and client.usage.get_history(...) from the same trusted backend context.

Account Memberships and Project Grants

Use these helpers from a trusted backend with a secret SPAPS key. Membership decisions need the secret key but no admin JWT. Admin mutations require an admin access token; do not call membership invitation, project grant, or project revoke helpers from browser code.

from spaps_client import EntitlementsClient, UsersClient

admin_token = "admin-access-token"

users = UsersClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token=admin_token,
)
entitlements = EntitlementsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token=admin_token,
)

try:
    membership = users.memberships.decide(
        "user_123",
        grant_allowed=True,
    )
    if membership.decision == "deny":
        raise RuntimeError(", ".join(membership.reasons))

    users.memberships.add_account_user(
        email="teammate@example.com",
        capabilities={"projects": ["project_123"]},
        metadata={"invited_by": "admin@example.com"},
    )
    entitlements.project_grants.grant_user(
        project_id="project_123",
        entitlement_key="pds.project.viewer",
        email="teammate@example.com",
        reason="Project teammate invite",
    )

    project_users = entitlements.project_grants.list_project_users(
        "project_123",
        entitlement_key="pds.project.viewer",
    )
    user_projects = entitlements.project_grants.list_user_projects("user_123")
    access = entitlements.project_grants.check_access(
        project_id="project_123",
        entitlement_key="pds.project.viewer",
        user_id="user_123",
    )
    print(project_users.count, user_projects.count, access.has_access)
finally:
    users.close()
    entitlements.close()

Token gates

list_gates() is API-key only. verify_gates() needs the secret key and the user JWT. UNVERIFIABLE means the server could not check holdings — unknown, not no. Concepts and presets: docs/guides/token-gates.md and docs/guides/token-gates-cookbook.md.

from spaps_client import (
    TokenGateAccessKind,
    TokenGatesClient,
    token_gate_access_decision,
)

gates = TokenGatesClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token="user-jwt",
)
try:
    listed = gates.list_gates()
    verified = gates.verify_gates(entitlement_key="fwa_holder")
    for row in verified.results:
        decision = token_gate_access_decision(row)
        if decision.kind is TokenGateAccessKind.ALLOW:
            entitled = True
        elif decision.kind is TokenGateAccessKind.DENY:
            entitled = False
        elif decision.kind is TokenGateAccessKind.UNVERIFIABLE:
            entitled = None
        else:
            raise RuntimeError(f"unknown token-gate access kind: {decision.kind}")
    print(listed.count, entitled)
finally:
    gates.close()

Marketing Events

Use client.marketing.emit_event(...) with a publishable key to record anonymous attribution touches or experiment exposures. Use a secret key from a trusted server or agent to read experiment results and the current conservative stop signal.

from spaps_client import SpapsClient

browser_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    browser_client.marketing.emit_event(
        anon_id="anon_01HY...",
        event_type="experiment_exposure",
        experiment_id="landing-hero-copy",
        variant_id="treatment",
        dedupe_key="landing-hero-copy:anon_01HY:treatment",
    )
finally:
    browser_client.close()

agent_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    results = agent_client.marketing.get_experiment_results("landing-hero-copy")
    print(results.decision.recommendation, results.decision.winner_variant_id)
finally:
    agent_client.close()

Permission Checks With Explicit Admin Config

from spaps_client import PermissionChecker

checker = PermissionChecker(customAdmins=["admin@example.com"])
role = checker.getRole("operator@example.com")

if checker.requiresAdmin({"email": "operator@example.com"}):
    raise PermissionError(
        checker.getErrorMessage("admin", role, action="change billing settings")
    )

Issue Reporting Voice Token

Voice issue reporting uses a short-lived SPAPS token for ElevenLabs Scribe. Keep the ElevenLabs API key on the SPAPS server.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    voice_token = client.issue_reporting.create_voice_token()
    print(voice_token.provider, voice_token.model_id)
finally:
    client.close()

Issue Reporting Screenshot Attachments

Screenshots are uploaded as private pending hosted assets first. Create, update, or reply calls then attach those IDs; raw image bytes, data URLs, and base64 payloads should not be stored in issue notes or target metadata.

from pathlib import Path

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    attachment = client.issue_reporting.upload_issue_report_attachment(
        file=Path("protocol-save-failure.png").read_bytes(),
        filename="protocol-save-failure.png",
        mime_type="image/png",
    )

    issue = client.issue_reporting.create_issue_report(
        target={
            "component_key": "patient_protocol_widget",
            "component_label": "Patient Protocol Widget",
            "page_url": "/patients/123/protocol",
            "surface_ref": "daily-log",
            "metadata": {"section": "daily log"},
        },
        note="The save action silently fails after I edit today's protocol note.",
        reporter_role_hint="practitioner",
        attachment_ids=[attachment.id],
    )

    access = client.issue_reporting.get_issue_report_attachment_access(
        attachment_id=attachment.id,
    )
    print(issue.id, access.expires_in_seconds)
finally:
    client.close()

SPAPS accepts PNG, JPEG, and WebP screenshots up to 10 MiB each, with at most 5 retained screenshots per report. The hosted object remains private; callers fetch a short-lived access URL after normal issue-reporting authorization succeeds. SPAPS does not redact screenshot contents, so host apps should warn users when a capture may include sensitive data.

Skill Evals

Skill eval helpers wrap the SPAPS blind review endpoints for agent-skill logs. Paid case creation accepts a PAYMENT-SIGNATURE through payment_signature. Reviewers submit valuable and not_valuable marks, and submitters read those marks through an insight inbox before applying skill changes.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    created = client.skill_evals.create_case(
        {
            "title": "Docs skill comparison",
            "task_claim": "Compare both implementations.",
            "success_criteria": ["Finds repo boundaries"],
            "candidates": [
                {
                    "candidate_id": "A",
                    "output_ref": "spaps-artifact://case/a",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/a.jsonl",
                    "jsonl_log_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.0",
                    "skill_version_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/a",
                },
                {
                    "candidate_id": "B",
                    "output_ref": "spaps-artifact://case/b",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/b.jsonl",
                    "jsonl_log_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.1",
                    "skill_version_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/b",
                },
            ],
            "case_policy": {
                "access_mode": "team_private",
                "allowed_model_efforts": [
                    {"model_id": "openai/gpt-5.4", "effort_level": "medium"}
                ],
                "participant_allowlist": ["reviewer-actor-id"],
            },
            "idempotency_key": "eval-create-001",
        },
        payment_signature=signed_payment,
    )
    room = client.skill_evals.get_review_room(created["case_id"])
    print(room["reviewer_state"])
    review = client.skill_evals.submit_review(
        created["case_id"],
        {
            "review_marks": [
                {
                    "candidate_id": "B",
                    "kind": "valuable",
                    "note": "B checks the configured docs path before recommending an edit.",
                    "reason_code": "prevents_wrong_repo_patch",
                    "confidence": "high",
                    "criterion": "Finds repo boundaries",
                }
            ]
        },
    )
    inbox = client.skill_evals.get_insights(created["case_id"])
    print(inbox["valuable"][0]["jsonl_log_ref"], review["review_mark_counts"])
    client.skill_evals.respond_to_review(
        created["case_id"],
        inbox["valuable"][0]["source_review_id"],
        {
            "response": "applied",
            "reason": "Updated the skill from the concrete log-backed insight.",
            "applied_insight_ref": inbox["valuable"][0]["insight_ref"],
            "skill_change_ref": "skill://docs-review/v2.1",
            "skill_version_before": "skill://docs-review/v2.0",
            "skill_version_after": "skill://docs-review/v2.1",
            "jsonl_log_ref": "spaps-artifact://logs/apply.jsonl",
            "jsonl_log_hash": (
                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
            ),
            "model_id": "openai/gpt-5.4",
            "effort_level": "medium",
        },
    )
finally:
    client.close()

Webhook Verification

from spaps_client import verify_spaps_webhook

payload = verify_spaps_webhook(
    body=request_body_bytes,
    signature=request_headers["X-SPAPS-Signature"],
    secret="whsec_example",
)

print(payload.type)

Validation

From the repository root:

npm run lint:python-client
npm run typecheck:python-client
npm run test:python-client

For local package work:

cd packages/python-client
pip install -e '.[dev]'

Troubleshooting

ValueError: Access token not found

Authenticate first with client.auth... helpers, or seed tokens with your configured token storage.

401 or 403 responses

Check the API key, access token, and endpoint role requirements for the target environment.

Hosted examples fail against localhost

Set base_url="http://localhost:3301" and use a local development key or test credentials.

I need more control over HTTP behavior

Inject a custom httpx client or configure RetryConfig and logging hooks.

Which client should I start with?

Use SpapsClient unless your service is already async end to end.

Limitations

  • Some specialty integrations live as standalone helpers instead of methods on the main client.
  • Endpoint coverage tracks the active SPAPS backend surface; new backend features may appear here incrementally.
  • You still need to provide environment-specific API keys, tokens, and deployment URLs.

FAQ

Is the package name different from the import path?

Yes. Install spaps; import from spaps_client.

Does it support async codebases?

Yes. Use AsyncSpapsClient and the async helper classes.

Is webhook verification included?

Yes. Use verify_spaps_webhook.

Can I replace the default token storage?

Yes. Pass a custom token_storage implementation.

The default is InMemoryTokenStorage. For persistence, pass FileTokenStorage(path=...): it replaces the token file only after a complete write, keeps file permissions owner-only on POSIX, and rejects symlinked storage directories. Loading ignores symlinked or malformed token files.

Does it retry requests automatically?

It can. Pass RetryConfig if you want retry and backoff behavior.

Metadata

The metadata below describes this checkout. latest_version is the source package version; it does not establish registry publication or availability.

  • package_name: spaps
  • latest_version: 0.8.2
  • minimum_runtime: Python >=3.9
  • api_base_url: https://api.sweetpotato.dev

About Contributions

About Contributions: Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via gh and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.

License

MIT

CFO: server decision and browser enrollment

client.cfo.decide_access(...) and client.cfo.start_browser_relay(...) are server-only. The async client exposes the same methods with await. Decision requires the CFO application's secret and the current human bearer; allowed=False is a valid HTTP 200 denial. Start requires a trusted CfoBrowserSession (or AsyncCfoBrowserSession), current human bearer, and the real browser's refresh cookie. Start does not send the application secret. No connection inventory, provider exchange, callback, or delegation-freshen helper is exposed.

These calls use dedicated one-shot HTTPX transports. Shared SDK retries, logging hooks, automatic refresh, and injected aggregate HTTP clients do not apply. Configure the host so httpcore DEBUG/wire logging is disabled or redacted; the facade cannot prevent secrets appearing in arbitrary host instrumentation.

# CFO decision example
from spaps_client import CfoClient, CfoDecision


def decide_cfo_report(cfo: CfoClient, company_id: str) -> CfoDecision:
    return cfo.decide_access(
        company_id=company_id,
        effect="write",
        action="report.read",
        correlation_id="test:1",
    )

Construct a dedicated client with CfoClient(base_url=spaps_origin, api_key=application_secret, access_token_provider=current_access_token), or use an authenticated aggregate's client.cfo. The provider is called for each decision; it must return the current token, not a captured login token.

# CFO browser example
from spaps_client import CfoBrowserSession, CfoClient


def begin_cfo_enrollment(
    cfo: CfoClient, bridge: CfoBrowserSession, company_id: str
) -> str:
    return cfo.start_browser_relay(
        company_id=company_id, action="enroll", browser_session=bridge
    )

The bridge constructor requires spaps_origin, allowed application origin, browser user_agent, credentials_provider, invalidate_access, and response_handler. The credentials provider returns CfoBrowserCredentials(access_token=..., cookie_header=...) from the current authenticated browser request after host CSRF validation. Only the configured refresh cookie and spaps_cfo_context are forwarded. Use the same bridge for overlapping attempts in one browser context; concurrent attempts fail closed. Different browser contexts require different bridges.

response_handler(status, cookies, location) receives ordered raw allowlisted Set-Cookie values, including deletions and failure responses. It must apply them at the actual SPAPS browser origin before forwarding an optional validated Intuit navigation. A BFF on an unrelated hostname cannot set SPAPS host-only cookies. The callback URLs and cookies are sensitive trusted bridge inputs: do not serialize them into ordinary application results or logs. A local cookie sink does not provision this deployed bridge.

On refresh-cookie rotation, invalidate_access must invalidate the cached bearer. Before another deliberate call, the host must reacquire current access through shared SPAPS auth, apply any further cookie updates, and expose that new bearer. Neither adapter refreshes or replays the operation. CfoError exposes only bounded code, status, and optional request_id; it does not retain the raw HTTP error. Deployment still requires the real origin/CSRF/cookie bridge, safe host logging, external CFO executor adoption, and the human retirement gate. These checkout APIs do not establish published package availability.

Metadata

Release files for spaps 0.8.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spaps 0.8.2
File Size Uploaded Provenance Actions
spaps-0.8.2.tar.gz 226.1 kB Without provenance Details for spaps-0.8.2.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for spaps 0.8.2
File Interpreter ABI Platform Actions
spaps-0.8.2-py3-none-any.whl Python 3 none any Details for spaps-0.8.2-py3-none-any.whl

Total release size: 389.3 kB

Release files / spaps-0.8.2.tar.gz

Download URL spaps-0.8.2.tar.gz
Size 226.1 kB
Tags Source
SHA-256 checksum
How to use checksums
77e2b75d6efdc7c669a9caa791a49afca4bbe953d879cad4cfaa2128714be11b
BLAKE2b-256 checksum
How to use checksums
b9c59d76317bd51bfcd6f4a5e92eab12535db299c8cd13907d00a29b0fce935a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / spaps-0.8.2-py3-none-any.whl

Download URL spaps-0.8.2-py3-none-any.whl
Size 163.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a36393c446f086299e8ef11ff42fe9707696ab3689668b046c905b2a87bdc753
BLAKE2b-256 checksum
How to use checksums
f40a853a2874f12f5e3ce24dc2e0ca65f2c0c2fd876cbab2a40d5d721d2eb7c1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Security for spaps 0.8.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page