Initial Python SDK release v1.0.0

This commit is contained in:
garfieldheron
2026-02-19 12:17:09 -05:00
commit 42443872c9
17 changed files with 1037 additions and 0 deletions

51
fetcherpay/__init__.py Normal file
View File

@@ -0,0 +1,51 @@
"""
FetcherPay Python SDK
One API. Every Rail.
Example:
>>> from fetcherpay import FetcherPay
>>>
>>> client = FetcherPay(
... api_key='fp_test_your_key',
... environment='sandbox'
... )
>>>
>>> payment = client.payments.create(
... amount=10000,
... currency='USD',
... source={'payment_method_id': 'pm_123'},
... destination={'payment_method_id': 'pm_456'}
... )
>>> print(payment.id)
"""
from .client import FetcherPay
from .exceptions import (
FetcherPayError,
AuthenticationError,
ValidationError,
NotFoundError,
)
from .types import (
Payment,
PaymentMethod,
LedgerAccount,
LedgerEntry,
WebhookEndpoint,
CreatePaymentRequest,
)
__version__ = '1.0.0'
__all__ = [
'FetcherPay',
'FetcherPayError',
'AuthenticationError',
'ValidationError',
'NotFoundError',
'Payment',
'PaymentMethod',
'LedgerAccount',
'LedgerEntry',
'WebhookEndpoint',
'CreatePaymentRequest',
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
# API modules

53
fetcherpay/api/ledger.py Normal file
View File

@@ -0,0 +1,53 @@
"""
Ledger API
"""
from typing import Optional
class LedgerAPI:
"""Ledger API client"""
def __init__(self, client):
self.client = client
def list_accounts(
self,
limit: int = 25,
cursor: Optional[str] = None,
type: Optional[str] = None
) -> dict:
"""List ledger accounts"""
params = {'limit': limit}
if cursor:
params['cursor'] = cursor
if type:
params['type'] = type
return self.client._request('GET', '/ledger/accounts', params=params)
def retrieve_account(self, account_id: str) -> dict:
"""Retrieve a ledger account"""
return self.client._request('GET', f'/ledger/accounts/{account_id}')
def list_entries(
self,
limit: int = 25,
cursor: Optional[str] = None,
account_id: Optional[str] = None,
payment_id: Optional[str] = None
) -> dict:
"""List ledger entries"""
params = {'limit': limit}
if cursor:
params['cursor'] = cursor
if account_id:
params['account_id'] = account_id
if payment_id:
params['payment_id'] = payment_id
return self.client._request('GET', '/ledger/entries', params=params)
def retrieve_entry(self, entry_id: str) -> dict:
"""Retrieve a ledger entry"""
return self.client._request('GET', f'/ledger/entries/{entry_id}')

View File

@@ -0,0 +1,73 @@
"""
Payment Methods API
"""
from typing import Optional, Dict, Any
class PaymentMethodsAPI:
"""Payment Methods API client"""
def __init__(self, client):
self.client = client
def create(
self,
type: str,
bank_account: Optional[Dict[str, Any]] = None,
card: Optional[Dict[str, Any]] = None,
usdc_wallet: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
idempotency_key: Optional[str] = None
) -> dict:
"""
Create a payment method
Args:
type: 'bank_account', 'card', or 'usdc_wallet'
bank_account: Bank account details
card: Card details
usdc_wallet: USDC wallet details
metadata: Optional metadata
idempotency_key: Idempotency key
"""
data = {'type': type}
if bank_account:
data['bank_account'] = bank_account
if card:
data['card'] = card
if usdc_wallet:
data['usdc_wallet'] = usdc_wallet
if metadata:
data['metadata'] = metadata
return self.client._request(
'POST',
'/payment-methods',
json=data,
idempotency_key=idempotency_key
)
def retrieve(self, payment_method_id: str) -> dict:
"""Retrieve a payment method"""
return self.client._request('GET', f'/payment-methods/{payment_method_id}')
def list(
self,
limit: int = 25,
cursor: Optional[str] = None,
type: Optional[str] = None
) -> dict:
"""List payment methods"""
params = {'limit': limit}
if cursor:
params['cursor'] = cursor
if type:
params['type'] = type
return self.client._request('GET', '/payment-methods', params=params)
def delete(self, payment_method_id: str) -> None:
"""Delete a payment method"""
self.client._request('DELETE', f'/payment-methods/{payment_method_id}')

133
fetcherpay/api/payments.py Normal file
View File

@@ -0,0 +1,133 @@
"""
Payments API
"""
from typing import Optional, Dict, Any, List
class PaymentsAPI:
"""Payments API client"""
def __init__(self, client):
self.client = client
def create(
self,
amount: int,
source: Dict[str, str],
destination: Dict[str, str],
currency: str = 'USD',
rail: str = 'auto',
description: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
idempotency_key: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new payment
Args:
amount: Amount in cents (e.g., 10000 for $100.00)
source: Payment source with payment_method_id
destination: Payment destination with payment_method_id
currency: Currency code (default: USD)
rail: Payment rail (auto, ach, rtp, card, crypto)
description: Optional payment description
metadata: Optional metadata dictionary
idempotency_key: Optional idempotency key
Returns:
Payment object
"""
data = {
'amount': amount,
'currency': currency,
'rail': rail,
'source': source,
'destination': destination,
}
if description:
data['description'] = description
if metadata:
data['metadata'] = metadata
return self.client._request(
'POST',
'/payments',
json=data,
idempotency_key=idempotency_key
)
def retrieve(self, payment_id: str) -> Dict[str, Any]:
"""Retrieve a payment by ID"""
return self.client._request('GET', f'/payments/{payment_id}')
def list(
self,
limit: int = 25,
cursor: Optional[str] = None,
status: Optional[str] = None,
rail: Optional[str] = None
) -> Dict[str, Any]:
"""
List payments
Args:
limit: Number of results (1-100)
cursor: Pagination cursor
status: Filter by status
rail: Filter by rail
"""
params = {'limit': limit}
if cursor:
params['cursor'] = cursor
if status:
params['status'] = status
if rail:
params['rail'] = rail
return self.client._request('GET', '/payments', params=params)
def cancel(
self,
payment_id: str,
reason: Optional[str] = None,
idempotency_key: Optional[str] = None
) -> Dict[str, Any]:
"""Cancel a pending payment"""
data = {'reason': reason} if reason else {}
return self.client._request(
'POST',
f'/payments/{payment_id}/cancel',
json=data,
idempotency_key=idempotency_key
)
def refund(
self,
payment_id: str,
amount: Optional[int] = None,
reason: Optional[str] = None,
idempotency_key: Optional[str] = None
) -> Dict[str, Any]:
"""
Refund a settled payment
Args:
payment_id: Payment ID to refund
amount: Amount to refund (omit for full refund)
reason: Refund reason
idempotency_key: Idempotency key
"""
data = {}
if amount:
data['amount'] = amount
if reason:
data['reason'] = reason
return self.client._request(
'POST',
f'/payments/{payment_id}/refund',
json=data,
idempotency_key=idempotency_key
)

View File

@@ -0,0 +1,80 @@
"""
Webhooks API
"""
from typing import Optional, List, Dict, Any
class WebhooksAPI:
"""Webhooks API client"""
def __init__(self, client):
self.client = client
def create(
self,
url: str,
events: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None,
idempotency_key: Optional[str] = None
) -> dict:
"""
Create a webhook endpoint
Args:
url: Endpoint URL
events: List of events to subscribe to
metadata: Optional metadata
idempotency_key: Idempotency key
"""
data = {'url': url}
if events:
data['events'] = events
if metadata:
data['metadata'] = metadata
return self.client._request(
'POST',
'/webhooks',
json=data,
idempotency_key=idempotency_key
)
def retrieve(self, webhook_id: str) -> dict:
"""Retrieve a webhook endpoint"""
return self.client._request('GET', f'/webhooks/{webhook_id}')
def list(
self,
limit: int = 25,
cursor: Optional[str] = None
) -> dict:
"""List webhook endpoints"""
params = {'limit': limit}
if cursor:
params['cursor'] = cursor
return self.client._request('GET', '/webhooks', params=params)
def update(
self,
webhook_id: str,
url: Optional[str] = None,
events: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None
) -> dict:
"""Update a webhook endpoint"""
data = {}
if url:
data['url'] = url
if events:
data['events'] = events
if metadata:
data['metadata'] = metadata
return self.client._request('PUT', f'/webhooks/{webhook_id}', json=data)
def delete(self, webhook_id: str) -> None:
"""Delete a webhook endpoint"""
self.client._request('DELETE', f'/webhooks/{webhook_id}')

153
fetcherpay/client.py Normal file
View File

@@ -0,0 +1,153 @@
"""
FetcherPay API Client
"""
import hmac
import hashlib
from typing import Optional
import requests
from requests.adapters import HTTPAdapter
from urllib.parse import urljoin
from .exceptions import (
FetcherPayError,
AuthenticationError,
ValidationError,
NotFoundError,
IdempotencyError,
)
from .api.payments import PaymentsAPI
from .api.ledger import LedgerAPI
from .api.payment_methods import PaymentMethodsAPI
from .api.webhooks import WebhooksAPI
class FetcherPay:
"""
Main FetcherPay client
Args:
api_key: Your FetcherPay API key
environment: 'sandbox' or 'production'
base_url: Optional custom base URL
timeout: Request timeout in seconds (default: 30)
"""
def __init__(
self,
api_key: str,
environment: str = 'sandbox',
base_url: Optional[str] = None,
timeout: int = 30
):
self.api_key = api_key
self.environment = environment
self.timeout = timeout
if base_url:
self.base_url = base_url
elif environment == 'production':
self.base_url = 'https://api.fetcherpay.com/v1'
else:
self.base_url = 'https://sandbox.fetcherpay.com/v1'
# Create session with retries
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
})
# Initialize API modules
self.payments = PaymentsAPI(self)
self.ledger = LedgerAPI(self)
self.payment_methods = PaymentMethodsAPI(self)
self.webhooks = WebhooksAPI(self)
def _request(
self,
method: str,
path: str,
params: Optional[dict] = None,
json: Optional[dict] = None,
headers: Optional[dict] = None,
idempotency_key: Optional[str] = None
) -> dict:
"""Make an HTTP request"""
url = urljoin(self.base_url, path)
request_headers = {}
if headers:
request_headers.update(headers)
if idempotency_key:
request_headers['Idempotency-Key'] = idempotency_key
try:
response = self.session.request(
method=method,
url=url,
params=params,
json=json,
headers=request_headers,
timeout=self.timeout
)
# Handle errors
if response.status_code >= 400:
self._handle_error(response)
return response.json() if response.content else {}
except requests.exceptions.Timeout:
raise FetcherPayError('Request timeout', 'timeout_error')
except requests.exceptions.ConnectionError:
raise FetcherPayError('Connection error', 'connection_error')
def _handle_error(self, response):
"""Handle API errors"""
try:
data = response.json()
error = data.get('error', {})
except:
error = {}
message = error.get('message', 'An error occurred')
error_type = error.get('type', 'api_error')
param = error.get('param')
code = error.get('code')
if response.status_code == 401:
raise AuthenticationError(message)
elif response.status_code == 404:
raise NotFoundError(message)
elif response.status_code == 422:
raise ValidationError(message, param)
elif response.status_code == 409:
raise IdempotencyError(message)
else:
raise FetcherPayError(message, error_type, response.status_code, param, code)
def verify_webhook_signature(
self,
payload: str,
signature: str,
secret: str
) -> bool:
"""
Verify a webhook signature
Args:
payload: The raw webhook payload string
signature: The signature from X-FetcherPay-Signature header
secret: Your webhook secret
Returns:
True if signature is valid
"""
expected = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)

49
fetcherpay/exceptions.py Normal file
View File

@@ -0,0 +1,49 @@
"""
FetcherPay SDK Exceptions
"""
class FetcherPayError(Exception):
"""Base FetcherPay error"""
def __init__(
self,
message: str,
type: str = 'api_error',
status_code: int = None,
param: str = None,
code: str = None
):
super().__init__(message)
self.type = type
self.status_code = status_code
self.param = param
self.code = code
class AuthenticationError(FetcherPayError):
"""Authentication failed"""
def __init__(self, message: str):
super().__init__(message, 'authentication_error', 401)
class ValidationError(FetcherPayError):
"""Validation failed"""
def __init__(self, message: str, param: str = None):
super().__init__(message, 'validation_error', 422, param)
class NotFoundError(FetcherPayError):
"""Resource not found"""
def __init__(self, message: str):
super().__init__(message, 'not_found', 404)
class IdempotencyError(FetcherPayError):
"""Idempotency key conflict"""
def __init__(self, message: str):
super().__init__(message, 'idempotency_error', 409)

153
fetcherpay/types.py Normal file
View File

@@ -0,0 +1,153 @@
"""
FetcherPay SDK Types
"""
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from enum import Enum
class PaymentStatus(str, Enum):
PENDING = 'pending'
AUTHORIZED = 'authorized'
PROCESSING = 'processing'
SETTLED = 'settled'
FAILED = 'failed'
CANCELLED = 'cancelled'
REFUNDED = 'refunded'
PARTIALLY_REFUNDED = 'partially_refunded'
class Rail(str, Enum):
AUTO = 'auto'
ACH = 'ach'
RTP = 'rtp'
CARD = 'card'
CRYPTO = 'crypto'
class AccountType(str, Enum):
ASSET = 'asset'
LIABILITY = 'liability'
REVENUE = 'revenue'
EXPENSE = 'expense'
EQUITY = 'equity'
@dataclass
class Fee:
amount: int
rate: str
@dataclass
class TimelineEvent:
status: str
timestamp: str
detail: str
@dataclass
class Refund:
id: str
payment_id: str
amount: int
reason: Optional[str]
status: str
created_at: str
@dataclass
class Payment:
id: str
object: str
status: PaymentStatus
amount: int
currency: str
rail: str
rail_selected: str
description: Optional[str]
source: Dict[str, Any]
destination: Dict[str, Any]
fee: Optional[Fee]
timeline: List[TimelineEvent]
ledger_entry_ids: List[str]
refunds: List[Refund]
idempotency_key: Optional[str]
metadata: Dict[str, Any]
created_at: str
updated_at: str
@dataclass
class PaymentMethod:
id: str
object: str
type: str
status: str
bank_account: Optional[Dict[str, Any]]
card: Optional[Dict[str, Any]]
usdc_wallet: Optional[Dict[str, Any]]
metadata: Dict[str, Any]
created_at: str
@dataclass
class LedgerBalance:
pending: int
posted: int
available: int
@dataclass
class LedgerAccount:
id: str
object: str
name: str
type: AccountType
currency: str
balance: LedgerBalance
metadata: Dict[str, Any]
created_at: str
updated_at: str
@dataclass
class LedgerEntry:
id: str
object: str
journal_id: str
account_id: str
payment_id: Optional[str]
entry_type: str
amount: int
currency: str
status: str
description: Optional[str]
metadata: Dict[str, Any]
created_at: str
@dataclass
class WebhookEndpoint:
id: str
object: str
url: str
events: List[str]
status: str
secret: str
metadata: Dict[str, Any]
created_at: str
@dataclass
class ListResponse:
data: List[Any]
has_more: bool
next_cursor: Optional[str]
# Request types
CreatePaymentRequest = Dict[str, Any]
CreatePaymentMethodRequest = Dict[str, Any]
CreateWebhookRequest = Dict[str, Any]