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" " 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 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 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 StripeClient HTTP engine boto3.client(), openai.OpenAI() PaymentIntents Resource class stripe.PaymentIntent, s3.objects StripeObject Response model stripe.PaymentIntent, ec2.Instance CardError Typed exception botocore.exceptions.ClientError stripe_error_from_response 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
How to Build a Python SDK From Scratch
Full Article
Original Source
Read the full article at Hackernoon →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.