From 42443872c96a52ee4a0e47494682d6678861ec3d Mon Sep 17 00:00:00 2001 From: garfieldheron Date: Thu, 19 Feb 2026 12:17:09 -0500 Subject: [PATCH] Initial Python SDK release v1.0.0 --- README.md | 158 ++++++++++++++++++ examples/basic.py | 51 ++++++ examples/webhook_verification.py | 41 +++++ fetcherpay/__init__.py | 51 ++++++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 1233 bytes fetcherpay/__pycache__/client.cpython-311.pyc | Bin 0 -> 6197 bytes .../__pycache__/exceptions.cpython-311.pyc | Bin 0 -> 3399 bytes fetcherpay/__pycache__/types.cpython-311.pyc | Bin 0 -> 6825 bytes fetcherpay/api/__init__.py | 1 + fetcherpay/api/ledger.py | 53 ++++++ fetcherpay/api/payment_methods.py | 73 ++++++++ fetcherpay/api/payments.py | 133 +++++++++++++++ fetcherpay/api/webhooks.py | 80 +++++++++ fetcherpay/client.py | 153 +++++++++++++++++ fetcherpay/exceptions.py | 49 ++++++ fetcherpay/types.py | 153 +++++++++++++++++ setup.py | 41 +++++ 17 files changed, 1037 insertions(+) create mode 100644 README.md create mode 100644 examples/basic.py create mode 100644 examples/webhook_verification.py create mode 100644 fetcherpay/__init__.py create mode 100644 fetcherpay/__pycache__/__init__.cpython-311.pyc create mode 100644 fetcherpay/__pycache__/client.cpython-311.pyc create mode 100644 fetcherpay/__pycache__/exceptions.cpython-311.pyc create mode 100644 fetcherpay/__pycache__/types.cpython-311.pyc create mode 100644 fetcherpay/api/__init__.py create mode 100644 fetcherpay/api/ledger.py create mode 100644 fetcherpay/api/payment_methods.py create mode 100644 fetcherpay/api/payments.py create mode 100644 fetcherpay/api/webhooks.py create mode 100644 fetcherpay/client.py create mode 100644 fetcherpay/exceptions.py create mode 100644 fetcherpay/types.py create mode 100644 setup.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed51955 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# FetcherPay Python SDK + +Official Python SDK for the FetcherPay API — One API. Every Rail. + +## Installation + +```bash +pip install fetcherpay +``` + +## Quick Start + +```python +from fetcherpay import FetcherPay + +client = FetcherPay( + api_key='fp_test_your_key', + environment='sandbox' # or 'production' +) + +# Create a payment +payment = client.payments.create( + amount=10000, # $100.00 in cents + currency='USD', + source={'payment_method_id': 'pm_123'}, + destination={'payment_method_id': 'pm_456'}, + rail='auto' # Auto-select optimal rail +) + +print(payment['id'], payment['status']) +``` + +## Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api_key` | str | required | Your FetcherPay API key | +| `environment` | str | 'sandbox' | 'sandbox' or 'production' | +| `base_url` | str | auto | Override base URL | +| `timeout` | int | 30 | Request timeout (seconds) | + +## API Reference + +### Payments + +```python +# Create payment +payment = client.payments.create( + amount=10000, + source={'payment_method_id': 'pm_123'}, + destination={'payment_method_id': 'pm_456'}, + idempotency_key='unique-key' +) + +# Retrieve payment +payment = client.payments.retrieve('pay_xxx') + +# List payments +payments = client.payments.list(limit=10) + +# Cancel payment +client.payments.cancel('pay_xxx', reason='Customer request') + +# Refund payment +client.payments.refund('pay_xxx', amount=5000, reason='Partial refund') +``` + +### Ledger + +```python +# List accounts +accounts = client.ledger.list_accounts() + +# Get account balance +account = client.ledger.retrieve_account('la_xxx') +print(account['balance']['available']) + +# List entries +entries = client.ledger.list_entries(account_id='la_xxx') +``` + +### Payment Methods + +```python +# Create bank account +pm = client.payment_methods.create( + type='bank_account', + bank_account={ + 'account_number': '000123456789', + 'routing_number': '011000015', + 'account_type': 'checking' + } +) + +# List payment methods +methods = client.payment_methods.list() +``` + +### Webhooks + +```python +# Create webhook endpoint +webhook = client.webhooks.create( + url='https://your-app.com/webhooks', + events=['payment.settled', 'payment.failed'] +) + +# Verify webhook signature +is_valid = client.verify_webhook_signature( + payload, + signature, # from X-FetcherPay-Signature header + webhook['secret'] +) +``` + +## Error Handling + +```python +from fetcherpay import ( + FetcherPayError, + AuthenticationError, + ValidationError, + NotFoundError +) + +try: + client.payments.create(...) +except AuthenticationError: + print('Invalid API key') +except ValidationError as e: + print(f'Validation failed: {e.param}') +except FetcherPayError as e: + print(f'API error: {e.type} ({e.status_code})') +``` + +## Webhook Verification + +```python +import json + +# In your webhook handler +def handle_webhook(request): + payload = request.body + signature = request.headers.get('X-FetcherPay-Signature') + secret = 'whsec_your_webhook_secret' + + if client.verify_webhook_signature(payload, signature, secret): + event = json.loads(payload) + if event['type'] == 'payment.settled': + handle_payment_settled(event['data']) + return 'OK', 200 + else: + return 'Invalid signature', 401 +``` + +## License + +MIT diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..a2d060b --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,51 @@ +""" +FetcherPay Python SDK - Basic Example +""" + +import os +from fetcherpay import FetcherPay + +# Initialize client +client = FetcherPay( + api_key=os.environ.get('FETCHERPAY_API_KEY', 'sandbox'), + environment='sandbox' +) + + +def main(): + try: + # Create a payment + print('Creating payment...') + payment = client.payments.create( + amount=10000, # $100.00 in cents + currency='USD', + source={'payment_method_id': 'pm_bank_123'}, + destination={'payment_method_id': 'pm_merchant_456'}, + rail='auto' + ) + print(f'Payment created: {payment["id"]} Status: {payment["status"]}') + + # Retrieve the payment + print('\nRetrieving payment...') + retrieved = client.payments.retrieve(payment['id']) + print(f'Retrieved: {retrieved["id"]} Timeline: {len(retrieved["timeline"])} events') + + # List payments + print('\nListing payments...') + payments = client.payments.list(limit=5) + print(f'Found {len(payments["data"])} payments') + + # List ledger accounts + print('\nListing ledger accounts...') + accounts = client.ledger.list_accounts() + print(f'Found {len(accounts["data"])} accounts') + for acc in accounts['data']: + print(f' - {acc["name"]}: ${acc["balance"]["available"] / 100} available') + + except Exception as e: + print(f'Error: {e}') + raise + + +if __name__ == '__main__': + main() diff --git a/examples/webhook_verification.py b/examples/webhook_verification.py new file mode 100644 index 0000000..ad64a04 --- /dev/null +++ b/examples/webhook_verification.py @@ -0,0 +1,41 @@ +""" +FetcherPay Python SDK - Webhook Verification Example +""" + +import json +from fetcherpay import FetcherPay + +# Initialize client +client = FetcherPay( + api_key='sandbox', + environment='sandbox' +) + +# Example webhook payload +payload = json.dumps({ + 'id': 'evt_123', + 'type': 'payment.settled', + 'created_at': '2026-02-18T20:00:00Z', + 'data': { + 'id': 'pay_456', + 'status': 'settled', + 'amount': 10000 + } +}) + +# Your webhook secret from the dashboard +webhook_secret = 'whsec_your_secret_here' + +# Simulate receiving a webhook +signature = 'sha256=...' # From X-FetcherPay-Signature header + +# Verify the signature +is_valid = client.verify_webhook_signature(payload, signature, webhook_secret) + +if is_valid: + print('✅ Webhook signature verified') + # Process the webhook event + event = json.loads(payload) + print(f'Event type: {event["type"]}') +else: + print('❌ Invalid webhook signature') diff --git a/fetcherpay/__init__.py b/fetcherpay/__init__.py new file mode 100644 index 0000000..7eee662 --- /dev/null +++ b/fetcherpay/__init__.py @@ -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', +] diff --git a/fetcherpay/__pycache__/__init__.cpython-311.pyc b/fetcherpay/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c8899779b5a4f2d712c5b5f5aa1ea7cf8445df2 GIT binary patch literal 1233 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 AAOHXW literal 0 HcmV?d00001 diff --git a/fetcherpay/__pycache__/exceptions.cpython-311.pyc b/fetcherpay/__pycache__/exceptions.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65297f5dcd0919c5be795cf3d59e18a318cb81cc GIT binary patch literal 3399 zcmcgu-)kd99G}VVW_Q!(`n#9fVz*Tfqu2`91K~=w)jLrk9u|Da%d+fFyu~z$vm3dV zfJKT{yoU;E&$m9+D}A}g`!iArTv!kU|A8DiLGH=#cQ%_JP0?PFn`A#bGvC>n@66}( z{qFvnOeP4F+U0%sdzp|wu@M!eRcZbVm3`unEQyi#i9>gY6JzvHBIFpZuw-MxCV@?6 zu~Rll*c7nE`)snXsbJIkY>KeyU`zDb;?BT2Ne^yKCvGy|zRmb8YtvYN?<3>J9h;SW zw^;BJcH|x22U?Bh8dSc84Kb3Xu&J0svyx82pOXOhWk-Q4&J;(5yT;^PVwdEoqwgfM z@m+Fr{vAQe7cA>5Cn207@mv^zA>3E=c=_b#7kb2V+Qs?F8MQ@iqAy*_<4Bf@wV@;~hQ>$noR_;1PP?0D$|^uP)D4<=IApOc?d)+4{s3_}NGf zDpL&t@W;q_L%~Lz42?EatZ8I&s-a^oL59Z~16UhGYx^Pb=EPc9&I}&75$gVkAs6&Bl+EIrZ z41H?lU8hSKBb|CoU$%W~DFgE< ze-W00EYXpKNPI#UI0@vkZegT4`{@ZIp^W zE7+SMnW?TG<8!uY*u_FF@7n%zr*y>a_|}T-U;c~QsV?t(1Nlwl0?+NNK)&_>=)GRk zu2-Y~ztH;{Ou`Y~z$yE(_k2~JM|zh-dY6&jb3?c+PM%4t;$0Hx?T_a0TSUA(L>9r_ z2uF@c_#A$)Jk^%NPoNdP2u(OJ;^8M#L!y*6VlgT=2ts0r!dFT^?75Gz_esa}vP|I> z)DQO-A7k&6j_Hz2F>jrXdqo_+Msa+56g&aa?}xpQaqtq3ZP{TYoMJ!heT5^JapY;c HIFx??1bxW| literal 0 HcmV?d00001 diff --git a/fetcherpay/__pycache__/types.cpython-311.pyc b/fetcherpay/__pycache__/types.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8bd8c09edab051b02c92eecafe2aea97abe4fa6 GIT binary patch literal 6825 zcmbstO>Y~=b;%v>E|(wrpd?$eW%(mbOSf@>I)}7%TqrX25zCZA$!$y|C05*VOq*Ys zU8a^%v@0}+90ZgH1?IsAom>>Y^p^w_-~%zI0zLI6!8+yC_hy%0ONzEpYW8XNy*In_ zzTfOWVliEU-w$V=6}DmaAG~S*6mRm{4ocE9$&|8^88CyzKq;6FmgKBlB3V)jWkUhP z%SEN6W>qmKMXeOhh67SiT9M4qx00z)orYf!NqPyF`^!cIRRyXxgc=ppFi`a&)EHF- zJp%OT3)tUFxZK|h`1i7XBW2?rXAC&wBRR)B&N1K|AIUlHaZUi|uA-FknVN*(8d^)2qQ#=2Vvb)C4#m!r{FK+ErsiprHpO_?E+o;-wO4%rKa;uQH zxx853;bgsF*~tJ8n}%)Vi-u*v4M~-2rF>^2FbQ}!udl)6nM9>*09-t12C}jl%o0=1 zhD?%G%urS}m8@o}*|4c)bu$bb(|HUwRH9{j(>Cmy#nmb;n}zaMTsLa=!wM@rr6$*_ ztdggeg?QDXwp|3I^1vwI|8U+Y=V=kA8lw+tWfNwTRfE|DqgdR@wQ=cWgloB6*(lLm zj_bKxsbbcOIFIIXU)PMHM^W#l(zn*qD_mc^x3#*FS<9wwas6&)V=1+{iFkE0wY9Yl zNM(6(4gZIi7Sl_qb)0IM)bhRbEtpN-UCeB)Ev~QM&$V&?lwPB%eoQ=YO;I&=ercl_ zu2pv!rVjydV*MI`)A?(~&p0csiFxN!7l_-~w6okAKj*Bq#%7(>*6asOGUE=m(0!uJ)1KlF(7ydoSfP`Tr3#dg<`?p;VPrw&~lA(l|BLAv?y2T*EOIFkvz1x z3BJL@>uZajtgWwY-REj1_4`!%UW%)!&%v)YQ(Q@Xb`L0iJz(rG1oLqD^$<-sDc97( z@*VSc^?)n;^dJKQf3M#I&^7iw333E612+ImLof^C%#^|0gv-kmq%%sW4NjP0({xht zTURasaV(cJ%H;}#EF=vJLP)l3<`?NV@MMPICvaJ4sjg`q>EF>Y=eBcu_jXHBJerxBH|GI94-sNaOuV-owSqQO}EuzB{*Sm>K$gt`yM#6?E^rlz&mXSH8&I6yNs%LzC;Unk3RQgy*92Arj&N_41_OjorRd(1 z^gh!?1U5pWeb}gn?KgAG!^Zk}SjnE+>*;;L7m*$Uve zQrUh)^S1B~*I%@JjWJrzL%+bVT*MIOELsFMsKgPIT6tCw?FEEhrN;79Anig~qz^7X zfV87wFk+1Iaj4{9QI>;HWjo-N&=a^N+~Q*ela{I#8{d|DOn10eg1E)*gnf#e(`!|@ z@w_#Dy0?m2+jUt2Ygq_^NLdQbM0gA5^~L=7hoKc3$5sQJyFls&+et0WDdq5nQKi<*Yl0*^@sbw zv;gCfEVRNC^(%X@4j+ZjiBHx)eigseBsdPqrPj<`*^*!TuA zlCfQcc4nZyi!3m#z8mleh9C#mV118`JL}H+?s`{+?Lv>`-YrYGdbo^Ff$|RFfR)}~ z(-pwEZVknVYP|sh6xWr}VNY>gS6^Q1+v~b`3SwWv{#Y6SSGPTtb{v&u0iRBWPfxGd zqzTorQ(cxK3xWr5Sq#A2x~x9kBsdPqbgPp^W0)1a#Q2Um3pX*`NdE%LI~b`qXZ6=! zUlRsdXoP_vOZBgA_X&eWc6$YUujmiV7-Xe5kGQ5yVfpMato{f}FC6fpqQcqQ%}3bt zt%8(XcZ;n^Ti65&+q*UXGra8>f27?pgqaAaQ|(?r76cYv-vfAq-P=W`r|g_FWiw?$LP)GSlH%H+Et=C=5l5w59f0QLL0uGxjX2Z7Iz8F zc>D7nwU~++0v|j|OoOT0gNS}ve5nzsU3AHdNJV{%W>~ZzZq65}UBtQY6x<4*S`x=d z?b|ejg`ch-+81W}v;!G$ob0iId@2=O$G2&0U z#PLBkv9x}rZV&KE!3YW$N8$Y77;z^Ej%DEccGl(8#Tq#tXl%`tqmkzERSO-6Q{>P>?*E(~qhX!OAA~pOE5B=(q literal 0 HcmV?d00001 diff --git a/fetcherpay/api/__init__.py b/fetcherpay/api/__init__.py new file mode 100644 index 0000000..eb82301 --- /dev/null +++ b/fetcherpay/api/__init__.py @@ -0,0 +1 @@ +# API modules diff --git a/fetcherpay/api/ledger.py b/fetcherpay/api/ledger.py new file mode 100644 index 0000000..463a0c8 --- /dev/null +++ b/fetcherpay/api/ledger.py @@ -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}') diff --git a/fetcherpay/api/payment_methods.py b/fetcherpay/api/payment_methods.py new file mode 100644 index 0000000..1fc070d --- /dev/null +++ b/fetcherpay/api/payment_methods.py @@ -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}') diff --git a/fetcherpay/api/payments.py b/fetcherpay/api/payments.py new file mode 100644 index 0000000..803016d --- /dev/null +++ b/fetcherpay/api/payments.py @@ -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 + ) diff --git a/fetcherpay/api/webhooks.py b/fetcherpay/api/webhooks.py new file mode 100644 index 0000000..a05b1f1 --- /dev/null +++ b/fetcherpay/api/webhooks.py @@ -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}') diff --git a/fetcherpay/client.py b/fetcherpay/client.py new file mode 100644 index 0000000..572bca7 --- /dev/null +++ b/fetcherpay/client.py @@ -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) diff --git a/fetcherpay/exceptions.py b/fetcherpay/exceptions.py new file mode 100644 index 0000000..0b2dcdd --- /dev/null +++ b/fetcherpay/exceptions.py @@ -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) diff --git a/fetcherpay/types.py b/fetcherpay/types.py new file mode 100644 index 0000000..95fb16e --- /dev/null +++ b/fetcherpay/types.py @@ -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] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2ac76d6 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="fetcherpay", + version="1.0.0", + author="FetcherPay", + author_email="support@fetcherpay.com", + description="FetcherPay Python SDK", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/fetcherpay/fetcherpay-python", + packages=find_packages(), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + python_requires=">=3.8", + install_requires=[ + "requests>=2.25.0", + "typing-extensions>=4.0.0", + ], + extras_require={ + "dev": [ + "pytest>=6.0", + "pytest-cov>=2.0", + "black>=21.0", + "isort>=5.0", + "mypy>=0.910", + ] + } +)