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.
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. 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:spapslatest_version:0.8.2minimum_runtime:Python >=3.9api_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
ghand 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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.