Every developer uses SDKs: pip install stripe, import stripe, done. But almost nobody knows what happens inside that black box. I built a complete Python SDK for the Stripe API from scratch, authentication, request handling, error management, retries, models, and tests. This article walks through every line. By the end, you will understand SDKs so deeply you could build one for any API in the world.
The Import Line I Never Questioned
import stripe
stripe.api_key = "sk_test_..."
charge = stripe.PaymentIntent.create(amount=2000, currency="usd")
I have written this code dozens of times. I never once stopped to ask what stripe actually is. What happens when I call .create()? Where does the HTTP request come from? How does it handle errors? What happens when Stripe’s API is slow?
One afternoon, driven by pure curiosity, I decided to find out. I deleted stripe from my project, banned myself from reinstalling it, and started building my own version from scratch.
What followed was the most educational thing I have done as a developer. Not because the code was hard, it was not. Because for the first time, I understood the layer of software I had been relying on every day without understanding at all.
This article is everything I learned. By the end you will have built a complete, working Python SDK for the Stripe API, and you will never look at an import statement the same way again.
Part 1: What Is an SDK?
Everyone knows the marketing answer: “Software Development Kit, a set of tools that make it easier to use an API.”
But what does that actually mean in practice?
Here is the honest breakdown.
An SDK is a library that wraps an API to give developers three things:
1. Language-native access to an HTTP API
APIs communicate over HTTP. Without an SDK, calling an API looks like this:
import requests
response = requests.post(
'https://api.stripe.com/v1/payment_intents',
auth=('sk_test_your_key', ''),
data={
'amount': 2000,
'currency': 'usd',
'payment_method_types[]': 'card'
}
)
if response.status_code == 200:
data = response.json()
payment_intent_id = data['id']
else:
error = response.json()['error']
raise Exception(f"Stripe error: {error['message']}")
With an SDK, the same thing looks like:
import stripe
stripe.api_key = "sk_test_your_key"
payment_intent = stripe.PaymentIntent.create(
amount=2000,
currency="usd"
)
The SDK is not magic. It is a layer that handles the HTTP call, authentication, response parsing, and error handling on your behalf. The HTTP call still happens, you just do not have to write it.
2. Errors as exceptions, not status codes
Without an SDK, you check response.status_code and parse error JSON yourself. With an SDK, errors become language-native exceptions:
try:
stripe.PaymentIntent.create(amount=2000, currency="usd")
except stripe.error.CardError as e:
print(f"Card declined: {e.user_message}")
except stripe.error.RateLimitError:
print("Too many requests — retry in a moment")
except stripe.error.AuthenticationError:
print("Invalid API key")
3. Objects instead of raw dictionaries
Without an SDK, API responses are raw dictionaries. With an SDK, they become objects:
# Without SDK — raw dictionary
response_dict = requests.get(...).json()
intent_id = response_dict['id'] # KeyError if 'id' is missing
# With SDK — object with attributes
payment_intent = stripe.PaymentIntent.retrieve('pi_123')
intent_id = payment_intent.id # AttributeError tells you exactly what's wrong
That is the complete definition of what an SDK is. Now let us build one.
Part 2: Setting Up the Project
We are building a Python SDK for Stripe. Not the whole thing, Stripe has hundreds of endpoints, but enough to understand every pattern that makes up a real SDK:
- Authentication
- HTTP request handling with retries
- Response parsing into objects
- Error handling with typed exceptions
- A clean, Pythonic interface
Create the project structure:
mkdir stripe-sdk
cd stripe-sdk
# Create the package structure
mkdir -p stripe_sdk/resources
mkdir tests
touch stripe_sdk/__init__.py
touch stripe_sdk/client.py
touch stripe_sdk/exceptions.py
touch stripe_sdk/models.py
touch stripe_sdk/resources/__init__.py
touch stripe_sdk/resources/payment_intents.py
touch stripe_sdk/resources/customers.py
touch tests/__init__.py
touch tests/test_payment_intents.py
# Install the only external dependency we will use
pip install requests pytest
Your project structure:
stripe-sdk/
├── stripe_sdk/
│ ├── __init__.py ← Package entry point
│ ├── client.py ← HTTP client (the engine)
│ ├── exceptions.py ← Custom error classes
│ ├── models.py ← Response objects
│ └── resources/
│ ├── __init__.py
│ ├── payment_intents.py ← PaymentIntent resource
│ └── customers.py ← Customer resource
└── tests/
└── test_payment_intents.py
Part 3: Error Handling First
Professional SDK developers write exceptions before anything else. Here is why: when you know what can go wrong, you design everything else around preventing those things and surfacing them clearly when they happen anyway.
Stripe has six categories of errors. Each one should be a separate exception class so developers can handle them differently:
# stripe_sdk/exceptions.py
class StripeError(Exception):
"""
Base exception for all Stripe SDK errors.
All other exceptions inherit from this.
Why a base class?
Developers can catch all Stripe errors with:
except StripeError:
Or catch specific ones with:
except CardError:
"""
def __init__(self, message: str = None, http_status: int = None,
error_code: str = None, request_id: str = None):
super().__init__(message)
self.message = message
self.http_status = http_status
self.error_code = error_code
self.request_id = request_id # For debugging with Stripe support
def __repr__(self):
return (
f"{self.__class__.__name__}("
f"message={self.message!r}, "
f"http_status={self.http_status}, "
f"error_code={self.error_code!r}, "
f"request_id={self.request_id!r})"
)
class CardError(StripeError):
"""
The card was declined.
This is the most common error in payment flows.
Should always be shown to the user with a friendly message.
Attributes:
user_message: Safe message to display to the end user
decline_code: Specific reason (e.g. 'insufficient_funds',
'card_velocity_exceeded', 'do_not_honor')
"""
def __init__(self, message: str, http_status: int = 402,
error_code: str = None, decline_code: str = None,
request_id: str = None):
super().__init__(message, http_status, error_code, request_id)
self.decline_code = decline_code
# Safe message for end users (never show raw API errors to users)
self.user_message = self._get_user_message(decline_code)
@staticmethod
def _get_user_message(decline_code: str) -> str:
"""Map Stripe decline codes to user-friendly messages"""
messages = {
'insufficient_funds': 'Your card has insufficient funds.',
'card_velocity_exceeded': 'Your card has reached its limit. Try another card.',
'do_not_honor': 'Your card was declined. Please contact your bank.',
'expired_card': 'Your card has expired. Please use a different card.',
'incorrect_cvc': 'Your card security code is incorrect.',
'lost_card': 'Your card was reported as lost. Please use a different card.',
'stolen_card': 'Your card cannot be used. Please contact your bank.',
}
return messages.get(
decline_code,
'Your card was declined. Please try a different card or contact your bank.'
)
class AuthenticationError(StripeError):
"""
Invalid API key.
Developer error — never shown to end users.
Usually means wrong api_key value or using test key in production.
"""
pass
class RateLimitError(StripeError):
"""
Too many requests sent in a short period.
The SDK handles this automatically with retry logic.
Raised to the developer only if all retries are exhausted.
"""
def __init__(self, message: str, retry_after: int = None, **kwargs):
super().__init__(message, **kwargs)
self.retry_after = retry_after # Seconds to wait before retrying
class InvalidRequestError(StripeError):
"""
The request had invalid parameters.
Developer error — fix the request before retrying.
Attributes:
param: The specific parameter that was invalid
"""
def __init__(self, message: str, param: str = None, **kwargs):
super().__init__(message, **kwargs)
self.param = param
class APIConnectionError(StripeError):
"""
Network error — could not connect to Stripe's servers.
May be transient. The SDK retries these automatically.
"""
pass
class APIError(StripeError):
"""
Stripe server error (5xx).
Not your fault. Stripe's servers had a problem.
The SDK retries these automatically.
"""
pass
Now write the function that maps Stripe’s JSON error responses to these exceptions:
# At the bottom of stripe_sdk/exceptions.py
def stripe_error_from_response(response) -> StripeError:
"""
Parse a Stripe error response and return the appropriate exception.
Stripe error response format:
{
"error": {
"type": "card_error",
"code": "insufficient_funds",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": null,
"request_id": "req_8Km3pXz9"
}
}
"""
try:
error_data = response.json().get('error', {})
except Exception:
error_data = {}
error_type = error_data.get('type', 'api_error')
message = error_data.get('message', 'An unknown error occurred')
error_code = error_data.get('code')
request_id = error_data.get('request_id') or response.headers.get('Request-Id')
http_status = response.status_code
# Map Stripe error types to our exception classes
if error_type == 'card_error':
return CardError(
message=message,
http_status=http_status,
error_code=error_code,
decline_code=error_data.get('decline_code'),
request_id=request_id
)
elif error_type == 'authentication_error' or http_status == 401:
return AuthenticationError(
message=message,
http_status=http_status,
error_code=error_code,
request_id=request_id
)
elif http_status == 429:
retry_after = int(response.headers.get('Retry-After', 60))
return RateLimitError(
message=message,
http_status=http_status,
error_code=error_code,
retry_after=retry_after,
request_id=request_id
)
elif error_type == 'invalid_request_error':
return InvalidRequestError(
message=message,
http_status=http_status,
error_code=error_code,
param=error_data.get('param'),
request_id=request_id
)
elif http_status >= 500:
return APIError(
message=message,
http_status=http_status,
error_code=error_code,
request_id=request_id
)
else:
return StripeError(
message=message,
http_status=http_status,
error_code=error_code,
request_id=request_id
)
Part 4: The Models
When Stripe returns a PaymentIntent, it is a JSON dictionary. Our SDK should turn it into a Python object. Here is how to build the model layer:
# stripe_sdk/models.py
class StripeObject:
"""
Base class for all Stripe API response objects.
Converts a dictionary response into an object with attribute access.
Nested dictionaries also become StripeObjects automatically.
Example:
# Raw API response
data = {
"id": "pi_123",
"amount": 2000,
"metadata": {"order_id": "order_456"}
}
# After conversion
obj = StripeObject(data)
print(obj.id) # "pi_123"
print(obj.amount) # 2000
print(obj.metadata.order_id) # "order_456" <- nested object!
"""
def __init__(self, data: dict):
if not isinstance(data, dict):
raise ValueError(f"Expected dict, got {type(data).__name__}")
# Store raw data for debugging and serialisation
self._raw_data = data
# Convert each key-value pair into an attribute
for key, value in data.items():
if isinstance(value, dict):
# Recursively convert nested dicts to StripeObjects
setattr(self, key, StripeObject(value))
elif isinstance(value, list):
# Convert list items that are dicts to StripeObjects
converted_list = [
StripeObject(item) if isinstance(item, dict) else item
for item in value
]
setattr(self, key, converted_list)
else:
setattr(self, key, value)
def __repr__(self):
"""Show object type and id when printed"""
obj_type = getattr(self, 'object', 'stripe_object')
obj_id = getattr(self, 'id', 'no_id')
return f"<{obj_type} id={obj_id!r}>"
def __getattr__(self, name):
"""
Return None for missing attributes instead of raising AttributeError.
This matches Stripe's SDK behaviour — missing fields return None,
not exceptions, because API responses can omit optional fields.
"""
# Only called when normal attribute lookup fails
return None
def to_dict(self) -> dict:
"""Convert back to a plain dictionary"""
return self._raw_data
class PaymentIntent(StripeObject):
"""
Represents a Stripe PaymentIntent object.
A PaymentIntent tracks the lifecycle of a payment from creation
through confirmation and capture.
Key attributes:
id: Unique identifier (e.g. "pi_3N5kLm2eZvKYlo2C1234")
amount: Amount in smallest currency unit (e.g. 2000 = $20.00)
currency: Three-letter ISO currency code (e.g. "usd")
status: Current status of the PaymentIntent
- requires_payment_method: Needs a card attached
- requires_confirmation: Ready to confirm
- requires_action: 3D Secure authentication needed
- processing: Payment is being processed
- succeeded: Payment completed successfully
- canceled: PaymentIntent was canceled
client_secret: Pass to Stripe.js to complete payment in browser
"""
@property
def is_succeeded(self) -> bool:
"""Shorthand to check if payment succeeded"""
return self.status == 'succeeded'
@property
def amount_in_dollars(self) -> float:
"""Convert amount from cents to dollars"""
if self.amount is None:
return 0.0
return self.amount / 100
class Customer(StripeObject):
"""
Represents a Stripe Customer object.
Customers store payment methods, email addresses, and metadata
for recurring billing and future payments.
Key attributes:
id: Unique identifier (e.g. "cus_NffrFeUfNV2Hib")
email: Customer's email address
name: Customer's full name
metadata: Key-value pairs you can attach for your own use
"""
pass
class ListObject(StripeObject):
"""
Represents a paginated list of Stripe objects.
Stripe returns lists in this format:
{
"object": "list",
"data": [...items...],
"has_more": true,
"url": "/v1/payment_intents"
}
Attributes:
data: List of StripeObjects
has_more: Whether there are more pages
url: The URL used to retrieve this list
"""
def __init__(self, data: dict, item_class=StripeObject):
super().__init__(data)
# Convert data array items to the appropriate class
raw_items = data.get('data', [])
self.data = [item_class(item) for item in raw_items]
def __iter__(self):
"""Allow: for item in list_object"""
return iter(self.data)
def __len__(self):
"""Allow: len(list_object)"""
return len(self.data)
def __getitem__(self, index):
"""Allow: list_object[0]"""
return self.data[index]
Part 5: The Engine of the SDK
This is the most important file in the SDK. Every API call goes through here. It handles:
- Authentication (attaching the API key to every request)
- Request formatting (URL encoding vs JSON body)
- Response parsing (calling the right model class)
- Error handling (calling
stripe_error_from_response) - Retry logic (retrying throttled or failed requests automatically)
# stripe_sdk/client.py
import time
import requests
from typing import Optional, Type
from .exceptions import (
StripeError, APIConnectionError, RateLimitError,
stripe_error_from_response
)
from .models import StripeObject
# Stripe API constants
STRIPE_API_BASE = 'https://api.stripe.com/v1'
STRIPE_API_VERSION = '2024-06-20' # Pin to specific version for stability
DEFAULT_TIMEOUT = 30 # seconds
MAX_RETRIES = 3
RETRY_DELAY = 1.0 # Base delay in seconds (doubles each retry)
# These HTTP status codes are safe to retry
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
class StripeClient:
"""
The HTTP client that powers every API call in the SDK.
This class handles:
- Authentication: attaches API key to every request
- Request construction: builds correct URL and body format
- Response parsing: converts JSON to StripeObject instances
- Error handling: converts HTTP errors to typed exceptions
- Retry logic: automatically retries transient failures
Usage:
client = StripeClient(api_key="sk_test_...")
response = client.get('/payment_intents/pi_123')
response = client.post('/payment_intents', data={'amount': 2000})
"""
def __init__(self, api_key: str, timeout: int = DEFAULT_TIMEOUT,
max_retries: int = MAX_RETRIES):
if not api_key:
raise ValueError(
"API key is required. Get yours from "
"https://dashboard.stripe.com/apikeys"
)
if not api_key.startswith(('sk_test_', 'sk_live_')):
raise ValueError(
f"Invalid API key format: {api_key[:8]}... "
"Keys should start with 'sk_test_' or 'sk_live_'"
)
self.api_key = api_key
self.timeout = timeout
self.max_retries = max_retries
# Persistent session for connection pooling (faster than new
# connection per request)
self._session = requests.Session()
self._session.headers.update(self._build_default_headers())
def _build_default_headers(self) -> dict:
"""
Build headers sent with every request.
These identify our SDK to Stripe for debugging.
"""
return {
'Authorization': f'Bearer {self.api_key}',
'Stripe-Version': STRIPE_API_VERSION,
'User-Agent': 'stripe-python-sdk-from-scratch/1.0.0',
'X-Stripe-Client-User-Agent': 'python-sdk/1.0.0',
}
def _build_url(self, path: str) -> str:
"""
Build the full URL for an API path.
Examples:
'/payment_intents' → 'https://api.stripe.com/v1/payment_intents'
'/payment_intents/pi_123' → 'https://api.stripe.com/v1/payment_intents/pi_123'
"""
# Normalise path — ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
return f'{STRIPE_API_BASE}{path}'
def _should_retry(self, response=None, exception=None, attempt: int = 0) -> bool:
"""
Determine whether a failed request should be retried.
Rules:
- Only retry if we have attempts remaining
- Retry on rate limits (429) — Stripe asks us to wait and retry
- Retry on server errors (5xx) — Stripe had a problem, not us
- Retry on network errors — transient connection issues
- NEVER retry client errors (400, 401, 402, 403, 404, 422)
These are our fault and will fail the same way if retried
"""
if attempt >= self.max_retries:
return False
if exception is not None:
# Network-level error (connection refused, timeout, etc.)
return isinstance(exception, (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout
))
if response is not None:
return response.status_code in RETRY_STATUS_CODES
return False
def _calculate_retry_delay(self, attempt: int, response=None) -> float:
"""
Calculate how long to wait before retrying.
Uses exponential backoff: 1s, 2s, 4s, 8s...
If Stripe sends a Retry-After header, use that instead.
"""
if response is not None:
retry_after = response.headers.get('Retry-After')
if retry_after:
return float(retry_after)
# Exponential backoff: 2^attempt seconds
return RETRY_DELAY * (2 ** attempt)
def _make_request(self, method: str, path: str,
params: dict = None, data: dict = None) -> dict:
"""
Make an HTTP request to the Stripe API with retry logic.
This is the core of the SDK. Every public method eventually
calls this.
Args:
method: HTTP method ('GET', 'POST', 'PATCH', 'DELETE')
path: API path (e.g. '/payment_intents/pi_123')
params: Query string parameters (for GET requests)
data: Request body parameters (for POST/PATCH requests)
Returns:
Parsed JSON response as a dictionary
Raises:
StripeError subclass: When the API returns an error
APIConnectionError: When we cannot connect to Stripe
"""
url = self._build_url(path)
last_exception = None
for attempt in range(self.max_retries + 1):
try:
if attempt > 0:
delay = self._calculate_retry_delay(attempt - 1)
print(f"Retry {attempt}/{self.max_retries} "
f"after {delay:.1f}s...")
time.sleep(delay)
response = self._session.request(
method=method.upper(),
url=url,
params=params,
# Stripe uses form-encoded bodies for POST, not JSON
# This is unusual — most modern APIs use JSON bodies
# but Stripe chose form encoding for historical reasons
data=data,
timeout=self.timeout
)
# Success — parse and return
if response.status_code in (200, 201, 204):
if response.status_code == 204:
return {} # No content (used for delete)
return response.json()
# Error — decide whether to retry or raise
if self._should_retry(response=response, attempt=attempt):
last_exception = stripe_error_from_response(response)
continue
# Non-retryable error — raise immediately
raise stripe_error_from_response(response)
except requests.exceptions.Timeout as e:
last_exception = APIConnectionError(
f"Request timed out after {self.timeout}s. "
f"Stripe may be slow — retry shortly. "
f"Original error: {e}"
)
if self._should_retry(exception=e, attempt=attempt):
continue
raise last_exception
except requests.exceptions.ConnectionError as e:
last_exception = APIConnectionError(
f"Could not connect to Stripe's servers. "
f"Check your internet connection. "
f"Original error: {e}"
)
if self._should_retry(exception=e, attempt=attempt):
continue
raise last_exception
# All retries exhausted
if last_exception:
raise last_exception
raise APIConnectionError(
f"Request failed after {self.max_retries} retries"
)
# Public interface methods — these are what resource classes call
def get(self, path: str, params: dict = None) -> dict:
"""Make a GET request"""
return self._make_request('GET', path, params=params)
def post(self, path: str, data: dict = None) -> dict:
"""Make a POST request"""
return self._make_request('POST', path, data=data)
def patch(self, path: str, data: dict = None) -> dict:
"""Make a PATCH request"""
return self._make_request('PATCH', path, data=data)
def delete(self, path: str) -> dict:
"""Make a DELETE request"""
return self._make_request('DELETE', path)
Part 6: The Developer-Facing Interface
Resources are what developers interact with. Each resource maps to a Stripe API object. Here is the Payment-Intent resource:
# stripe_sdk/resources/payment_intents.py
from typing import Optional, List
from ..models import PaymentIntent, ListObject
from ..exceptions import InvalidRequestError
class PaymentIntents:
"""
Interact with Stripe PaymentIntents.
A PaymentIntent is the core object for collecting payments.
It tracks the complete payment lifecycle and handles:
- Strong Customer Authentication (3D Secure)
- Multiple payment method types
- Automatic retries for failed payments
Usage:
sdk = StripeSDK(api_key="sk_test_...")
# Create a payment intent
intent = sdk.payment_intents.create(
amount=2000,
currency="usd"
)
# Retrieve it later
intent = sdk.payment_intents.retrieve("pi_123")
# Confirm it
intent = sdk.payment_intents.confirm("pi_123")
"""
def __init__(self, client):
self._client = client
def create(
self,
amount: int,
currency: str,
customer_id: Optional[str] = None,
description: Optional[str] = None,
metadata: Optional[dict] = None,
receipt_email: Optional[str] = None,
statement_descriptor: Optional[str] = None,
automatic_payment_methods: bool = True
) -> PaymentIntent:
"""
Create a new PaymentIntent.
Args:
amount: Amount in the smallest currency unit.
For USD: 2000 = $20.00, 100 = $1.00
For JPY: 2000 = ¥2000 (no decimals)
currency: Three-letter ISO currency code (e.g. "usd", "eur", "gbp")
customer_id: Stripe Customer ID to attach this payment to
description: Internal description (not shown to customer)
metadata: Up to 50 key-value pairs for your own use.
Perfect for storing order IDs, user IDs, etc.
receipt_email: Email to send payment receipt to
statement_descriptor: Text on customer's bank statement (max 22 chars)
automatic_payment_methods: When True, Stripe automatically
determines which payment methods to show (recommended)
Returns:
PaymentIntent object
Raises:
InvalidRequestError: If amount is not a positive integer
or currency is not a valid ISO code
AuthenticationError: If API key is invalid
"""
# Validate locally before making the API call
# This gives faster, clearer errors than waiting for Stripe
if not isinstance(amount, int) or amount <= 0:
raise InvalidRequestError(
f"amount must be a positive integer (got {amount!r}). "
f"Note: amounts are in the smallest currency unit "
f"(e.g. 2000 for $20.00 USD)",
param='amount'
)
if not isinstance(currency, str) or len(currency) != 3:
raise InvalidRequestError(
f"currency must be a 3-letter ISO code (e.g. 'usd', 'eur'). "
f"Got: {currency!r}",
param='currency'
)
if statement_descriptor and len(statement_descriptor) > 22:
raise InvalidRequestError(
f"statement_descriptor must be 22 characters or fewer. "
f"Got {len(statement_descriptor)} characters.",
param='statement_descriptor'
)
# Build request parameters
# Stripe uses form encoding, so nested objects use bracket notation:
# metadata[order_id] = "order_123"
params = {
'amount': amount,
'currency': currency.lower(),
}
if customer_id:
params['customer'] = customer_id
if description:
params['description'] = description
if receipt_email:
params['receipt_email'] = receipt_email
if statement_descriptor:
params['statement_descriptor'] = statement_descriptor
if automatic_payment_methods:
params['automatic_payment_methods[enabled]'] = 'true'
# Metadata is nested — Stripe expects bracket notation
if metadata:
for key, value in metadata.items():
params[f'metadata[{key}]'] = str(value)
response_data = self._client.post('/payment_intents', data=params)
return PaymentIntent(response_data)
def retrieve(self, payment_intent_id: str) -> PaymentIntent:
"""
Retrieve a PaymentIntent by ID.
Args:
payment_intent_id: The PaymentIntent ID (starts with 'pi_')
Returns:
PaymentIntent object with current state
Raises:
InvalidRequestError: If payment_intent_id is invalid
"""
if not payment_intent_id or not payment_intent_id.startswith('pi_'):
raise InvalidRequestError(
f"Invalid PaymentIntent ID: {payment_intent_id!r}. "
f"IDs should start with 'pi_'",
param='payment_intent_id'
)
response_data = self._client.get(
f'/payment_intents/{payment_intent_id}'
)
return PaymentIntent(response_data)
def update(
self,
payment_intent_id: str,
description: Optional[str] = None,
metadata: Optional[dict] = None,
receipt_email: Optional[str] = None
) -> PaymentIntent:
"""
Update a PaymentIntent.
Only update fields before the payment is confirmed.
Once status is 'processing' or 'succeeded', most fields are locked.
Args:
payment_intent_id: The PaymentIntent ID to update
description: New description
metadata: New metadata (merged with existing, not replaced)
receipt_email: New receipt email
Returns:
Updated PaymentIntent object
"""
params = {}
if description is not None:
params['description'] = description
if receipt_email is not None:
params['receipt_email'] = receipt_email
if metadata:
for key, value in metadata.items():
params[f'metadata[{key}]'] = str(value)
if not params:
# Nothing to update — return current state
return self.retrieve(payment_intent_id)
response_data = self._client.post(
f'/payment_intents/{payment_intent_id}',
data=params
)
return PaymentIntent(response_data)
def confirm(
self,
payment_intent_id: str,
payment_method: Optional[str] = None,
return_url: Optional[str] = None
) -> PaymentIntent:
"""
Confirm a PaymentIntent, attempting to collect payment.
Call this after attaching a payment method to the PaymentIntent.
If the PaymentIntent requires 3D Secure authentication, the
returned object will have status 'requires_action' and a
next_action field with instructions.
Args:
payment_intent_id: The PaymentIntent ID to confirm
payment_method: Payment method ID to confirm with (if not
already attached to the PaymentIntent)
return_url: URL to redirect to after 3D Secure authentication
Returns:
PaymentIntent with updated status
"""
params = {}
if payment_method:
params['payment_method'] = payment_method
if return_url:
params['return_url'] = return_url
response_data = self._client.post(
f'/payment_intents/{payment_intent_id}/confirm',
data=params if params else None
)
return PaymentIntent(response_data)
def cancel(
self,
payment_intent_id: str,
cancellation_reason: Optional[str] = None
) -> PaymentIntent:
"""
Cancel a PaymentIntent.
Can only cancel PaymentIntents with status:
- requires_payment_method
- requires_capture
- requires_confirmation
- requires_action
Args:
payment_intent_id: The PaymentIntent ID to cancel
cancellation_reason: Why it was cancelled.
Options: 'duplicate', 'fraudulent', 'requested_by_customer',
'abandoned'
Returns:
Canceled PaymentIntent (status will be 'canceled')
"""
params = {}
valid_reasons = {
'duplicate', 'fraudulent', 'requested_by_customer', 'abandoned'
}
if cancellation_reason:
if cancellation_reason not in valid_reasons:
raise InvalidRequestError(
f"Invalid cancellation_reason: {cancellation_reason!r}. "
f"Must be one of: {', '.join(sorted(valid_reasons))}",
param='cancellation_reason'
)
params['cancellation_reason'] = cancellation_reason
response_data = self._client.post(
f'/payment_intents/{payment_intent_id}/cancel',
data=params if params else None
)
return PaymentIntent(response_data)
def list(
self,
customer_id: Optional[str] = None,
limit: int = 10,
starting_after: Optional[str] = None,
ending_before: Optional[str] = None
) -> ListObject:
"""
List PaymentIntents with optional filtering.
Stripe uses cursor-based pagination (not page numbers).
Use starting_after with the last item's ID to get the next page.
Args:
customer_id: Filter to a specific customer's PaymentIntents
limit: Number of results (1-100, default 10)
starting_after: Cursor for next page (use last item's ID)
ending_before: Cursor for previous page
Returns:
ListObject containing data array and pagination info
Example — paginate through all results:
page = sdk.payment_intents.list(limit=10)
while True:
for intent in page:
process(intent)
if not page.has_more:
break
page = sdk.payment_intents.list(
limit=10,
starting_after=page[-1].id
)
"""
if not 1 <= limit <= 100:
raise InvalidRequestError(
f"limit must be between 1 and 100. Got {limit}",
param='limit'
)
params = {'limit': limit}
if customer_id:
params['customer'] = customer_id
if starting_after:
params['starting_after'] = starting_after
if ending_before:
params['ending_before'] = ending_before
response_data = self._client.get('/payment_intents', params=params)
return ListObject(response_data, item_class=PaymentIntent)
Now the Customer resource:
# stripe_sdk/resources/customers.py
from typing import Optional
from ..models import Customer, ListObject
from ..exceptions import InvalidRequestError
class Customers:
"""
Interact with Stripe Customers.
Customers store payment methods and metadata.
Creating customers lets you:
- Save payment methods for future use
- Track a customer's payment history
- Apply discounts and subscriptions
"""
def __init__(self, client):
self._client = client
def create(
self,
email: Optional[str] = None,
name: Optional[str] = None,
phone: Optional[str] = None,
description: Optional[str] = None,
metadata: Optional[dict] = None
) -> Customer:
"""
Create a new Customer.
All fields are optional — you can create a customer with no data
and update it later. However, providing email is strongly
recommended for receipt delivery and fraud prevention.
Args:
email: Customer's email address
name: Customer's full name
phone: Customer's phone number
description: Internal description
metadata: Custom key-value pairs (up to 50)
Returns:
Customer object
"""
params = {}
if email:
if '@' not in email:
raise InvalidRequestError(
f"Invalid email format: {email!r}",
param='email'
)
params['email'] = email
if name:
params['name'] = name
if phone:
params['phone'] = phone
if description:
params['description'] = description
if metadata:
for key, value in metadata.items():
params[f'metadata[{key}]'] = str(value)
response_data = self._client.post('/customers', data=params)
return Customer(response_data)
def retrieve(self, customer_id: str) -> Customer:
"""Retrieve a Customer by ID"""
if not customer_id.startswith('cus_'):
raise InvalidRequestError(
f"Invalid Customer ID: {customer_id!r}. "
f"IDs should start with 'cus_'",
param='customer_id'
)
response_data = self._client.get(f'/customers/{customer_id}')
return Customer(response_data)
def update(
self,
customer_id: str,
email: Optional[str] = None,
name: Optional[str] = None,
metadata: Optional[dict] = None
) -> Customer:
"""Update a Customer"""
params = {}
if email:
params['email'] = email
if name:
params['name'] = name
if metadata:
for key, value in metadata.items():
params[f'metadata[{key}]'] = str(value)
response_data = self._client.post(
f'/customers/{customer_id}', data=params
)
return Customer(response_data)
def delete(self, customer_id: str) -> dict:
"""
Delete a Customer.
This permanently deletes the customer. Any associated
PaymentIntents and subscriptions are unaffected but the
customer cannot be retrieved after deletion.
Returns:
Dict with {"deleted": true, "id": "cus_..."}
"""
return self._client.delete(f'/customers/{customer_id}')
def list(self, email: Optional[str] = None,
limit: int = 10) -> ListObject:
"""List Customers, optionally filtered by email"""
params = {'limit': limit}
if email:
params['email'] = email
response_data = self._client.get('/customers', params=params)
return ListObject(response_data, item_class=Customer)
Part 7: The Entry Point Tying It All Together
# stripe_sdk/__init__.py
"""
Stripe SDK — built from scratch.
Usage:
from stripe_sdk import StripeSDK
sdk = StripeSDK(api_key="sk_test_...")
# Create a payment intent
intent = sdk.payment_intents.create(amount=2000, currency="usd")
print(intent.id)
# Create a customer
customer = sdk.customers.create(email="emma@example.com")
print(customer.id)
"""
from .client import StripeClient
from .resources.payment_intents import PaymentIntents
from .resources.customers import Customers
from .exceptions import (
StripeError,
CardError,
AuthenticationError,
RateLimitError,
InvalidRequestError,
APIConnectionError,
APIError
)
from .models import PaymentIntent, Customer, ListObject
__version__ = '1.0.0'
__all__ = [
'StripeSDK',
'StripeError',
'CardError',
'AuthenticationError',
'RateLimitError',
'InvalidRequestError',
'APIConnectionError',
'APIError',
]
class StripeSDK:
"""
The main entry point for the Stripe SDK.
This class owns an HTTP client and exposes resource objects
that use that client to make API calls.
The pattern:
sdk = StripeSDK(api_key)
sdk.payment_intents.create(...) ← PaymentIntents resource
sdk.customers.create(...) ← Customers resource
Adding a new resource (e.g. Subscriptions) requires:
1. Create stripe_sdk/resources/subscriptions.py
2. Add self.subscriptions = Subscriptions(self._client) here
That's it — the HTTP client is shared automatically.
Args:
api_key: Your Stripe secret key (starts with sk_test_ or sk_live_)
timeout: Request timeout in seconds (default: 30)
max_retries: Maximum retry attempts for transient errors (default: 3)
"""
def __init__(self, api_key: str, timeout: int = 30, max_retries: int = 3):
# The shared HTTP client — one instance, used by all resources
self._client = StripeClient(
api_key=api_key,
timeout=timeout,
max_retries=max_retries
)
# Resource objects — each one gets the shared client
self.payment_intents = PaymentIntents(self._client)
self.customers = Customers(self._client)
def __repr__(self):
key_preview = f"{self._client.api_key[:12]}..."
return f"StripeSDK(api_key={key_preview!r})"
Part 8: How to Verify Your SDK Works
# tests/test_payment_intents.py
import pytest
from unittest.mock import MagicMock, patch
from stripe_sdk import StripeSDK
from stripe_sdk.exceptions import InvalidRequestError, CardError
from stripe_sdk.models import PaymentIntent, ListObject
# Sample API responses (mirrors real Stripe response format)
SAMPLE_PAYMENT_INTENT = {
"id": "pi_3N5kLm2eZvKYlo2C1234567",
"object": "payment_intent",
"amount": 2000,
"currency": "usd",
"status": "requires_payment_method",
"client_secret": "pi_3N5kLm2eZvKYlo2C1234567_secret_abc123",
"description": "Order #12345",
"metadata": {"order_id": "order_12345"},
"created": 1721209800,
"livemode": False
}
SAMPLE_CARD_ERROR = {
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"request_id": "req_abc123"
}
}
class TestPaymentIntentValidation:
"""Test that our SDK validates inputs before making API calls"""
def setup_method(self):
self.sdk = StripeSDK(api_key="sk_test_fake_key_for_testing")
def test_create_rejects_zero_amount(self):
with pytest.raises(InvalidRequestError) as exc_info:
self.sdk.payment_intents.create(amount=0, currency="usd")
assert exc_info.value.param == 'amount'
assert 'positive integer' in str(exc_info.value.message)
def test_create_rejects_negative_amount(self):
with pytest.raises(InvalidRequestError) as exc_info:
self.sdk.payment_intents.create(amount=-100, currency="usd")
assert exc_info.value.param == 'amount'
def test_create_rejects_float_amount(self):
"""Amounts must be integers (20.00 → 2000)"""
with pytest.raises(InvalidRequestError) as exc_info:
self.sdk.payment_intents.create(amount=20.00, currency="usd")
assert exc_info.value.param == 'amount'
def test_create_rejects_invalid_currency(self):
with pytest.raises(InvalidRequestError) as exc_info:
self.sdk.payment_intents.create(amount=2000, currency="dollars")
assert exc_info.value.param == 'currency'
def test_create_rejects_long_statement_descriptor(self):
with pytest.raises(InvalidRequestError) as exc_info:
self.sdk.payment_intents.create(
amount=2000,
currency="usd",
statement_descriptor="This is way too long for a bank statement"
)
assert exc_info.value.param == 'statement_descriptor'
def test_retrieve_rejects_invalid_id_format(self):
with pytest.raises(InvalidRequestError) as exc_info:
self.sdk.payment_intents.retrieve("not_a_valid_id")
assert 'pi_' in str(exc_info.value.message)
class TestPaymentIntentAPICallsMocked:
"""Test SDK behaviour with mocked API responses"""
def setup_method(self):
self.sdk = StripeSDK(api_key="sk_test_fake_key_for_testing")
@patch('requests.Session.request')
def test_create_returns_payment_intent_object(self, mock_request):
"""Create should return a PaymentIntent model object"""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = SAMPLE_PAYMENT_INTENT
mock_request.return_value = mock_response
result = self.sdk.payment_intents.create(
amount=2000,
currency="usd"
)
# Verify it is the right type
assert isinstance(result, PaymentIntent)
# Verify attributes are set correctly
assert result.id == "pi_3N5kLm2eZvKYlo2C1234567"
assert result.amount == 2000
assert result.currency == "usd"
assert result.status == "requires_payment_method"
# Verify nested object access
assert result.metadata.order_id == "order_12345"
@patch('requests.Session.request')
def test_create_sends_correct_parameters(self, mock_request):
"""Verify the HTTP request includes all expected parameters"""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = SAMPLE_PAYMENT_INTENT
mock_request.return_value = mock_response
self.sdk.payment_intents.create(
amount=5000,
currency="gbp",
description="Test payment",
metadata={"order_id": "order_789"}
)
# Verify the HTTP call was made with correct parameters
mock_request.assert_called_once()
call_kwargs = mock_request.call_args[1]
assert call_kwargs['data']['amount'] == 5000
assert call_kwargs['data']['currency'] == 'gbp'
assert call_kwargs['data']['description'] == 'Test payment'
assert call_kwargs['data']['metadata[order_id]'] == 'order_789'
@patch('requests.Session.request')
def test_card_declined_raises_card_error(self, mock_request):
"""API card errors should raise CardError with user message"""
mock_response = MagicMock()
mock_response.status_code = 402
mock_response.json.return_value = SAMPLE_CARD_ERROR
mock_response.headers = {}
mock_request.return_value = mock_response
with pytest.raises(CardError) as exc_info:
self.sdk.payment_intents.create(amount=2000, currency="usd")
error = exc_info.value
assert error.decline_code == 'insufficient_funds'
assert 'insufficient funds' in error.user_message.lower()
assert error.http_status == 402
def test_payment_intent_is_succeeded_property(self):
"""Test the helper property"""
succeeded_intent = PaymentIntent(
{**SAMPLE_PAYMENT_INTENT, 'status': 'succeeded'}
)
failed_intent = PaymentIntent(
{**SAMPLE_PAYMENT_INTENT, 'status': 'requires_payment_method'}
)
assert succeeded_intent.is_succeeded is True
assert failed_intent.is_succeeded is False
def test_amount_in_dollars_conversion(self):
"""Test cents to dollars conversion"""
intent = PaymentIntent(SAMPLE_PAYMENT_INTENT)
assert intent.amount_in_dollars == 20.00
# Run: pytest tests/ -v
Run the tests:
pytest tests/ -v
# Expected output:
# tests/test_payment_intents.py::TestPaymentIntentValidation::test_create_rejects_zero_amount PASSED
# tests/test_payment_intents.py::TestPaymentIntentValidation::test_create_rejects_negative_amount PASSED
# tests/test_payment_intents.py::TestPaymentIntentValidation::test_create_rejects_float_amount PASSED
# tests/test_payment_intents.py::TestPaymentIntentValidation::test_create_rejects_invalid_currency PASSED
# tests/test_payment_intents.py::TestPaymentIntentValidation::test_create_rejects_long_statement_descriptor PASSED
# tests/test_payment_intents.py::TestPaymentIntentValidation::test_retrieve_rejects_invalid_id_format PASSED
# tests/test_payment_intents.py::TestPaymentIntentAPICallsMocked::test_create_returns_payment_intent_object PASSED
# tests/test_payment_intents.py::TestPaymentIntentAPICallsMocked::test_create_sends_correct_parameters PASSED
# tests/test_payment_intents.py::TestPaymentIntentAPICallsMocked::test_card_declined_raises_card_error PASSED
# tests/test_payment_intents.py::TestPaymentIntentAPICallsMocked::test_payment_intent_is_succeeded_property PASSED
# tests/test_payment_intents.py::TestPaymentIntentAPICallsMocked::test_amount_in_dollars_conversion PASSED
#
# 11 passed in 0.42s
Part 9: Use Your SDK End to End
Now use everything you built:
# example_usage.py
# A complete payment flow using our SDK
import os
from stripe_sdk import StripeSDK
from stripe_sdk.exceptions import CardError, AuthenticationError, StripeError
# Initialise with test key from environment
sdk = StripeSDK(
api_key=os.environ.get('STRIPE_TEST_KEY', 'sk_test_your_key_here'),
timeout=30,
max_retries=3
)
def process_order(order_id: str, amount_cents: int,
customer_email: str, description: str) -> dict:
"""
Complete payment flow:
1. Create or retrieve customer
2. Create payment intent
3. Return client_secret for frontend to complete payment
"""
print(f"n{'='*50}")
print(f"Processing order {order_id}")
print(f"Amount: ${amount_cents/100:.2f}")
print(f"Customer: {customer_email}")
print(f"{'='*50}n")
try:
# Step 1: Create the customer
print("1. Creating customer...")
customer = sdk.customers.create(
email=customer_email,
metadata={
'source': 'web_checkout',
'signup_date': '2026-07-17'
}
)
print(f" Customer created: {customer.id}")
# Step 2: Create the payment intent
print("n2. Creating payment intent...")
payment_intent = sdk.payment_intents.create(
amount=amount_cents,
currency="usd",
customer_id=customer.id,
description=description,
receipt_email=customer_email,
metadata={
'order_id': order_id,
'customer_id': customer.id
},
automatic_payment_methods=True
)
print(f" PaymentIntent created: {payment_intent.id}")
print(f" Status: {payment_intent.status}")
print(f" Amount: ${payment_intent.amount_in_dollars:.2f}")
# Step 3: Return what the frontend needs
result = {
'payment_intent_id': payment_intent.id,
'client_secret': payment_intent.client_secret,
'customer_id': customer.id,
'amount': payment_intent.amount,
'currency': payment_intent.currency,
'status': payment_intent.status
}
print(f"n Payment intent ready for frontend")
print(f" Pass client_secret to Stripe.js to complete payment")
return result
except CardError as e:
# Show this message to the user
print(f"n Card declined: {e.user_message}")
print(f" Decline code: {e.decline_code}")
print(f" Request ID: {e.request_id}")
raise
except AuthenticationError:
# Developer error — never show to users
print("n Invalid API key. Check your STRIPE_TEST_KEY")
raise
except StripeError as e:
# Unexpected error — log it, show generic message to user
print(f"n Stripe error: {e.message}")
print(f" HTTP status: {e.http_status}")
print(f" Request ID: {e.request_id}")
raise
def list_recent_payments(limit: int = 5):
"""List recent payment intents"""
print(f"n{'='*50}")
print(f"Recent Payment Intents (last {limit})")
print(f"{'='*50}")
payment_intents = sdk.payment_intents.list(limit=limit)
print(f"Found {len(payment_intents)} payment intents")
for intent in payment_intents:
status_emoji = "" if intent.is_succeeded else ""
print(f"n{status_emoji} {intent.id}")
print(f" Amount: ${intent.amount_in_dollars:.2f} {intent.currency.upper()}")
print(f" Status: {intent.status}")
if intent.description:
print(f" Description: {intent.description}")
if payment_intents.has_more:
print(f"n→ There are more payment intents.")
print(f" To get the next page:")
print(f" sdk.payment_intents.list(starting_after='{payment_intents[-1].id}')")
# Run it
if __name__ == '__main__':
try:
result = process_order(
order_id='order_2026_001',
amount_cents=4999, # $49.99
customer_email='emma@example.com',
description='Cloud Architecture Course — Annual Plan'
)
print(f"nClient secret (send to frontend): {result['client_secret'][:30]}...")
except StripeError:
print("nPayment flow failed. See error above.")
list_recent_payments(limit=3)
What You Built And What It Teaches You
Let us look at the complete file structure of the SDK we built:
stripe-sdk/
├── stripe_sdk/
│ ├── __init__.py # Entry point — StripeSDK class
│ ├── client.py # HTTP engine — auth, retries, error handling
│ ├── exceptions.py # Typed errors — CardError, AuthError, etc.
│ ├── models.py # Response objects — PaymentIntent, Customer
│ └── resources/
│ ├── payment_intents.py # Full CRUD + list for PaymentIntents
│ └── customers.py # Full CRUD + list for Customers
└── tests/
└── test_payment_intents.py # 11 tests covering validation and API calls
Many API SDKs, including tools like Stripe, Twilio, Boto3, OpenAI, and Anthropic, are built around some version of this pattern.
|
Our SDK |
What It Does |
Equivalent in Real SDKs |
|---|---|---|
|
|
HTTP engine |
|
|
|
Resource class |
|
|
|
Response model |
|
|
|
Typed exception |
|
|
|
Error parser |
Every SDK has one internally |
The next time you import stripe or import boto3 or from openai import OpenAI — you know exactly what is happening inside that package. It is an HTTP client, a set of resource classes, a set of model classes, and a set of exception classes. Nothing more, nothing less.
That is the secret of SDKs. Now you know it.
References
[1] Stripe. Stripe Python Library Source Code. https://github.com/stripe/stripe-python
[2] Stripe. Stripe API Reference. https://stripe.com/docs/api
[3] Python. Requests Library Documentation. https://docs.python-requests.org/en/latest/
[4] Pytest. pytest Documentation. https://docs.pytest.org/en/stable/
[5] Fern. API Documentation Best Practices 2026. https://buildwithfern.com/post/api-documentation-best-practices-guide
[6] DreamFactory. The 8 Best API Documentation Examples. May 2026. https://blog.dreamfactory.com/8-api-documentation-examples