Skip to main content

Sweet Potato Authentication & Payment Service Python client

Project description

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

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()
credential = client.auth.webauthn_register_verify(
    challenge_id=options.challenge_id,
    credential={"id": "browser-produced-credential"},
)

WebAuthn helpers intentionally pass browser-produced credential JSON through to the API; the Python client does not emulate authenticators or perform CTAP operations.

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, and support telemetry
AsyncSpapsClient Async auth, sessions, payments, usage, whitelist, secure messages, issue reporting, metrics, entitlements, app links, dayrate, skill evals, and users

Standalone Helpers

Helper Purpose
EntitlementsClient / AsyncEntitlementsClient Resource entitlements, purchase history, manual grants/revokes, and project grants
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 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

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

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.

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.

Magic Links and Password Reset

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. Until 0.6.2 is published to PyPI, consume a local wheel or source checkout from the Sweet Potato commit that carries the 0.6.2 changelog entry.

Release execution is intentionally separate from this handoff: run the manual Publish Python Client workflow when ready so release automation builds the already-bumped package metadata and publishes to PyPI. 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. Admin mutation helpers also 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:
    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()

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.

Does it retry requests automatically?

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

Metadata

  • package_name: spaps
  • latest_version: 0.6.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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spaps-0.6.2.tar.gz (130.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spaps-0.6.2-py3-none-any.whl (119.8 kB view details)

Uploaded Python 3

File details

Details for the file spaps-0.6.2.tar.gz.

File metadata

  • Download URL: spaps-0.6.2.tar.gz
  • Upload date:
  • Size: 130.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for spaps-0.6.2.tar.gz
Algorithm Hash digest
SHA256 79269c1d9600834fc41169fcc8eb96f0bd08f24539fc0c0ec3ab2476c495e0ee
MD5 f4c167636cdc547837f31b5c68c0abef
BLAKE2b-256 9b62536717877f8959b50d82c93c9898a064cc2c955b83031cab923a34369cea

See more details on using hashes here.

File details

Details for the file spaps-0.6.2-py3-none-any.whl.

File metadata

  • Download URL: spaps-0.6.2-py3-none-any.whl
  • Upload date:
  • Size: 119.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for spaps-0.6.2-py3-none-any.whl
Algorithm Hash digest
SHA256 a2d87078d88632dcff34df130a95254a8e446b7e0eff749266c1f57099efb0a2
MD5 3ab6141eba54aaee7a95c5428b36bda5
BLAKE2b-256 6878dca51d130d5974a49741b0742710067a6864691439a79f764d9b8ac53ed3

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page