How to Build a Python SDK From Scratch

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

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

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.