Repository: turnkeyintern/python-sdk
Analysis Date: March 2, 2026
Python Version: 3.8+
The Turnkey Python SDK provides a type-safe HTTP client for interacting with the Turnkey API. It follows the same patterns established by the TypeScript SDK but adapted to Python idioms.
- Type-safe API calls with full Pydantic model definitions
- Code generation from OpenAPI spec (Swagger) for types and HTTP client
- Request stamping with ECDSA P-256 signatures for authentication
- Activity polling for async operations
- Monorepo architecture with separate packages for types, HTTP client, and stamper
The SDK is organized as a monorepo with three pip-installable packages:
python-sdk/
├── packages/
│ ├── sdk-types/ # turnkey-sdk-types
│ │ ├── src/turnkey_sdk_types/
│ │ │ ├── __init__.py
│ │ │ ├── errors.py # Error classes
│ │ │ ├── types.py # Non-generated types
│ │ │ └── generated/
│ │ │ └── types.py # 7,700+ lines of generated Pydantic models
│ │ └── tests/
│ │
│ ├── http/ # turnkey-http
│ │ ├── src/turnkey_http/
│ │ │ ├── __init__.py
│ │ │ ├── version.py
│ │ │ └── generated/
│ │ │ └── client.py # ~5,000 lines, auto-generated HTTP methods
│ │ └── tests/
│ │
│ └── api-key-stamper/ # turnkey-api-key-stamper
│ └── src/turnkey_api_key_stamper/
│ ├── __init__.py
│ └── stamper.py # ~100 lines, ECDSA signing
│
├── codegen/ # Code generation scripts
│ ├── constants.py
│ ├── utils.py
│ ├── types/
│ │ ├── generate_types.py
│ │ └── pydantic_helpers.py
│ └── http/
│ └── generate_http.py
│
├── schema/
│ └── public_api.swagger.json # OpenAPI spec from Turnkey
│
├── pyproject.toml # Root project config
└── Makefile # Build commands
| Tool | Purpose |
|---|---|
| setuptools | Build system (not Poetry/uv) |
| pip | Package installation |
| ruff | Code formatting |
| mypy | Type checking |
| pytest | Testing |
# Example from pyproject.toml
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
requires-python = ">=3.8"| Package | Purpose |
|---|---|
pydantic>=2.0.0 |
Type definitions and validation |
requests>=2.31.0 |
HTTP client (sync only) |
cryptography>=41.0.0 |
ECDSA signing for API key stamping |
The main entry point for API interactions:
from turnkey_http import TurnkeyClient
from turnkey_api_key_stamper import ApiKeyStamper, ApiKeyStamperConfig
# Initialize
config = ApiKeyStamperConfig(
api_public_key="your-api-public-key",
api_private_key="your-api-private-key"
)
stamper = ApiKeyStamper(config)
client = TurnkeyClient(
base_url="https://api.turnkey.com",
stamper=stamper,
organization_id="your-org-id",
default_timeout=30, # seconds
polling_interval_ms=1000, # milliseconds
max_polling_retries=3
)Key Methods:
- Query methods:
get_whoami(),get_wallets(),get_users(), etc. - Activity methods:
create_wallet(),sign_transaction(),create_api_keys(), etc. - Stamping methods:
stamp_create_wallet(),stamp_get_whoami(), etc. - Generic:
send_signed_request(signed_request, response_type)
Handles request signing:
@dataclass
class ApiKeyStamperConfig:
"""Configuration for API key stamper."""
api_public_key: str
api_private_key: str
@dataclass
class TStamp:
"""Stamp result containing header name and value."""
stamp_header_name: str
stamp_header_value: str
class ApiKeyStamper:
def stamp(self, content: str) -> TStamp:
"""Create an authentication stamp for the given content."""All API types are Pydantic models with a shared base:
from pydantic import BaseModel, ConfigDict
class TurnkeyBaseModel(BaseModel):
model_config = ConfigDict(populate_by_name=True) # Support field aliases
# Example generated type
class v1Wallet(TurnkeyBaseModel):
walletId: str = Field(description="Unique identifier for a Wallet.")
walletName: str = Field(description="Human-readable name for a Wallet.")
accounts: List[v1WalletAccount]
createdAt: externaldatav1Timestamp
updatedAt: externaldatav1Timestamp
...For each API endpoint, three types are generated:
# Body type (what you pass in)
class CreateWalletBody(TurnkeyBaseModel):
timestampMs: Optional[str] = None
organizationId: Optional[str] = None
walletName: str
accounts: List[v1WalletAccountParams]
...
# Response type (what you get back)
class CreateWalletResponse(TurnkeyBaseModel):
activity: v1Activity
walletId: Optional[str] = None # Flattened from result
addresses: Optional[List[str]] = None
...
# Input type (wrapper, less commonly used)
class CreateWalletInput(TurnkeyBaseModel):
body: CreateWalletBodyclass TurnkeyErrorCodes(str, Enum):
NETWORK_ERROR = "NETWORK_ERROR"
BAD_RESPONSE = "BAD_RESPONSE"
class TurnkeyError(Exception):
def __init__(self, message: str, code: Optional[TurnkeyErrorCodes], cause: Any):
self.code = code
self.cause = cause
class TurnkeyNetworkError(TurnkeyError):
def __init__(self, message: str, status_code: int, code: TurnkeyErrorCodes, cause: Any):
self.status_code = status_codeFor stamp-then-send workflows:
class RequestType(Enum):
QUERY = "query"
ACTIVITY = "activity"
ACTIVITY_DECISION = "activityDecision"
@dataclass
class SignedRequest:
url: str
body: str
stamp: TStamp
type: RequestType = RequestType.QUERY- Serialize request body to JSON string
- Sign with ECDSA P-256 using the API private key
- Create stamp object with public key, scheme, and signature
- Base64url encode the stamp JSON (no padding)
- Attach as
X-Stampheader
def stamp(self, content: str) -> TStamp:
# Sign content with ECDSA
signature = _sign_with_api_key(
self.api_public_key,
self.api_private_key,
content
)
# Build stamp object
stamp = {
"publicKey": self.api_public_key,
"scheme": "SIGNATURE_SCHEME_TK_API_P256",
"signature": signature,
}
# Encode to base64url (no padding)
stamp_header_value = (
urlsafe_b64encode(json.dumps(stamp).encode())
.decode()
.rstrip("=") # Remove padding
)
return TStamp(
stamp_header_name="X-Stamp",
stamp_header_value=stamp_header_value,
)The stamper validates that the provided public key matches the private key:
def _sign_with_api_key(public_key: str, private_key: str, content: str) -> str:
# Derive private key from hex
ec_private_key = ec.derive_private_key(
int(private_key, 16), ec.SECP256R1(), default_backend()
)
# Get the public key to validate
public_key_obj = ec_private_key.public_key()
public_key_bytes = public_key_obj.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.CompressedPoint,
)
derived_public_key = public_key_bytes.hex()
# Validate
if derived_public_key != public_key:
raise ValueError(f"Bad API key. Expected {public_key}, got {derived_public_key}")
# Sign
signature = ec_private_key.sign(content.encode(), ec.ECDSA(hashes.SHA256()))
return signature.hex()TERMINAL_ACTIVITY_STATUSES = [
"ACTIVITY_STATUS_COMPLETED",
"ACTIVITY_STATUS_FAILED",
"ACTIVITY_STATUS_CONSENSUS_NEEDED",
"ACTIVITY_STATUS_REJECTED",
]def _poll_for_completion(self, activity: Any) -> Any:
"""Poll until activity reaches terminal status."""
if activity.status in TERMINAL_ACTIVITY_STATUSES:
return activity
attempts = 0
while attempts < self.max_polling_retries:
time.sleep(self.polling_interval_ms / 1000.0)
poll_response = self.get_activity(GetActivityBody(activityId=activity.id))
activity = poll_response.activity
if activity.status in TERMINAL_ACTIVITY_STATUSES:
break
attempts += 1
return activityWhen an activity completes, result fields are flattened into the response:
def _activity(self, url, body, result_key, response_type):
# Make initial request
initial_response = self._request(url, body, GetActivityResponse)
# Poll for completion
activity = self._poll_for_completion(initial_response.activity)
# Flatten result fields if completed
if activity.status == "ACTIVITY_STATUS_COMPLETED" and activity.result:
result = activity.result
if hasattr(result, result_key):
result_data = getattr(result, result_key)
if result_data:
result_dict = result_data.model_dump(by_alias=True, exclude_none=True)
# Construct response with activity AND result fields
return response_type(activity=activity, **result_dict)
return response_type(activity=activity)Example: CreateWalletResponse has both activity and flattened walletId/addresses.
The Python SDK currently uses the synchronous requests library:
import requests
response = requests.post(
full_url,
headers=headers,
data=body_str,
timeout=self.default_timeout
)Unlike the TypeScript SDK which has native async/await, the Python SDK:
- Uses
time.sleep()for polling delays - Blocks on HTTP requests
- Has no
async defmethods
To add async support, the SDK would need:
httpxoraiohttpfor async HTTPasyncio.sleep()for polling- Parallel
AsyncTurnkeyClientclass
# Query (sync, no polling)
response = client.get_whoami()
print(response.organizationId)
# Activity (makes request, polls, flattens result)
response = client.create_wallet(CreateWalletBody(
walletName="My Wallet",
accounts=[v1WalletAccountParams(
curve=v1Curve.CURVE_SECP256K1,
pathFormat=v1PathFormat.PATH_FORMAT_BIP32,
path="m/44'/60'/0'/0/0",
addressFormat=v1AddressFormat.ADDRESS_FORMAT_ETHEREUM,
)]
))
print(response.walletId) # Flattened from resultFor more control (e.g., signing on a different machine):
# Stamp without sending
signed_request = client.stamp_create_wallet(CreateWalletBody(
walletName="My Wallet",
accounts=[...]
))
# Later: send the signed request
response = client.send_signed_request(signed_request, CreateWalletResponse)The client has a default organization_id, but it can be overridden per-request:
# Use client's default org
response = client.get_organization()
# Override for this request
response = client.get_organization(GetOrganizationBody(
organizationId="different-org-id"
))from turnkey_sdk_types import TurnkeyNetworkError
try:
response = client.create_wallet(CreateWalletBody(...))
except TurnkeyNetworkError as e:
print(f"Error: {e}")
print(f"Status code: {e.status_code}")
print(f"Error code: {e.code}")The SDK uses Pydantic v2 with modern features:
from pydantic import BaseModel, Field, ConfigDict
class TurnkeyBaseModel(BaseModel):
model_config = ConfigDict(populate_by_name=True) # Allow alias OR field nameKey Pydantic patterns:
model_dump(by_alias=True, exclude_none=True)for serializationField(alias="@type")for JSON fields that aren't valid Python identifiersOptional[T] = Nonefor optional fields
Non-Pydantic types use @dataclass:
from dataclasses import dataclass
@dataclass
class ApiKeyStamperConfig:
api_public_key: str
api_private_key: str
@dataclass
class TStamp:
stamp_header_name: str
stamp_header_value: strString enums for API values:
from enum import Enum
class v1Curve(str, Enum):
CURVE_SECP256K1 = "CURVE_SECP256K1"
CURVE_ED25519 = "CURVE_ED25519"For send_signed_request:
from typing import overload, TypeVar
T = TypeVar('T')
@overload
def send_signed_request(self, signed_request: SignedRequest, response_type: type[T]) -> T: ...
@overload
def send_signed_request(self, signed_request: SignedRequest) -> Any: ...
def send_signed_request(self, signed_request, response_type=None):
# Implementation- Types: Generated from OpenAPI definitions using custom Python scripts
- HTTP Client: Generated from OpenAPI paths
- Version handling: Automatic resolution of versioned activity types
# From constants.py - version mappings
VERSIONED_ACTIVITY_TYPES = {
"ACTIVITY_TYPE_CREATE_USERS": (
"ACTIVITY_TYPE_CREATE_USERS_V3",
"v1CreateUsersIntentV3",
"v1CreateUsersResult",
),
...
}Generates Pydantic models from OpenAPI definitions:
- Base types: Direct conversion from
#/definitions/ - API types: Request bodies, responses, inputs for each endpoint
- Field handling: Descriptions, optionality, aliases
Generates client methods:
- Query methods: Direct request, no polling
- Activity methods: Request + poll + flatten
- Activity decision methods: approve/reject (no polling)
- Stamp methods: Return
SignedRequestwithout sending
The codegen handles API versioning:
# Unversioned → Versioned
"ACTIVITY_TYPE_CREATE_USERS" → "ACTIVITY_TYPE_CREATE_USERS_V3"
# Intent type resolution
"ACTIVITY_TYPE_CREATE_USERS" → "v1CreateUsersIntentV3"
# Result type resolution
"ACTIVITY_TYPE_CREATE_USERS" → "v1CreateUsersResult"| Aspect | TypeScript SDK | Python SDK |
|---|---|---|
| Async | Native async/await | Sync only (requests) |
| Types | Zod schemas | Pydantic v2 models |
| HTTP Client | fetch/node-fetch | requests |
| Monorepo | npm workspaces | pip editable installs |
| Build | tsup/tsc | setuptools |
| Package Manager | npm/pnpm | pip |
| Code Gen | Custom TS scripts | Custom Python scripts |
- Same OpenAPI spec as source of truth
- Same stamping algorithm (ECDSA P-256)
- Same activity polling pattern
- Same result flattening approach
- Same package split (types, http, stamper)
- Python SDK is sync-only (no asyncio)
- Uses Pydantic instead of Zod
- Uses dataclasses for simple types
- Simpler module structure (no barrel exports)
# Clone and create venv
git clone <repo>
cd python-sdk
python3 -m venv venv
source venv/bin/activate
# Install all packages in editable mode
make installmake generate # Both types and HTTP client
make generate-types # Types only
make generate-http # HTTP client only# Set up .env file with credentials
cp packages/http/tests/.env.example packages/http/tests/.env
# Run tests
make testmake format # Format with ruff
make typecheck # Type check with mypy- Should an async client be added?
- Would it be a separate package or same package with async variants?
- What HTTP library (httpx, aiohttp)?
- TypeScript SDK has WebAuthn support - should Python have it?
- What's the use case for Python + WebAuthn?
- Should there be connection pooling?
- Rate limiting helpers?
- Currently only polling retries, no HTTP retries
- Should exponential backoff be added?
- No structured logging currently
- Debug mode for request/response logging?
- TypeScript has
tkcli- should Python have a CLI? - Or focus on being a library only?
- Transaction builders?
- Wallet helpers?
- Address derivation utilities?
The Turnkey Python SDK is a well-structured, type-safe SDK that follows Python best practices:
Strengths:
- Excellent type coverage with Pydantic
- Clean package separation
- Robust code generation from OpenAPI
- Good test coverage patterns
Areas for Enhancement:
- Add async support
- More comprehensive documentation
- Example applications
- Higher-level abstractions
The SDK is production-ready for synchronous use cases and provides a solid foundation for Python developers integrating with Turnkey.