snippe 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
snippe/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Snippe - Python SDK for Snippe Payment API.
3
+
4
+ Accept payments via mobile money, card, and QR code.
5
+
6
+ Usage:
7
+ >>> from snippe import Snippe, Customer
8
+ >>> client = Snippe("your_api_key")
9
+ >>> payment = client.create_mobile_payment(
10
+ ... amount=1000,
11
+ ... currency="TZS",
12
+ ... phone_number="0788500000",
13
+ ... customer=Customer(firstname="John", lastname="Doe")
14
+ ... )
15
+ >>> print(payment.reference)
16
+ """
17
+
18
+ from .client import AsyncSnippe, Snippe
19
+ from .exceptions import (
20
+ AuthenticationError,
21
+ NotFoundError,
22
+ RateLimitError,
23
+ ServerError,
24
+ SnippeError,
25
+ ValidationError,
26
+ WebhookVerificationError,
27
+ )
28
+ from .models import (
29
+ Balance,
30
+ Customer,
31
+ Payment,
32
+ PaymentDetails,
33
+ PaymentList,
34
+ WebhookPayload,
35
+ )
36
+ from .types import Currency, PaymentStatus, PaymentType, WebhookEvent
37
+ from .webhooks import WebhookHandler, verify_webhook
38
+
39
+ __version__ = "0.1.0"
40
+ __all__ = [
41
+ # Clients
42
+ "Snippe",
43
+ "AsyncSnippe",
44
+ # Models
45
+ "Customer",
46
+ "Payment",
47
+ "PaymentDetails",
48
+ "PaymentList",
49
+ "Balance",
50
+ "WebhookPayload",
51
+ # Types
52
+ "PaymentType",
53
+ "PaymentStatus",
54
+ "Currency",
55
+ "WebhookEvent",
56
+ # Webhooks
57
+ "WebhookHandler",
58
+ "verify_webhook",
59
+ # Exceptions
60
+ "SnippeError",
61
+ "AuthenticationError",
62
+ "ValidationError",
63
+ "NotFoundError",
64
+ "RateLimitError",
65
+ "ServerError",
66
+ "WebhookVerificationError",
67
+ ]
snippe/client.py ADDED
@@ -0,0 +1,496 @@
1
+ """Snippe Payment API client."""
2
+
3
+ import uuid
4
+ from typing import Any, Optional
5
+
6
+ import httpx
7
+
8
+ from .exceptions import (
9
+ AuthenticationError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ ServerError,
13
+ SnippeError,
14
+ ValidationError,
15
+ )
16
+ from .models import Balance, Customer, Payment, PaymentDetails, PaymentList
17
+ from .types import Currency, PaymentType
18
+
19
+
20
+ class Snippe:
21
+ """
22
+ Snippe Payment API client.
23
+
24
+ Usage:
25
+ >>> from snippe import Snippe, Customer
26
+ >>> client = Snippe("your_api_key")
27
+ >>> payment = client.create_mobile_payment(
28
+ ... amount=1000,
29
+ ... currency="TZS",
30
+ ... phone_number="0788500000",
31
+ ... customer=Customer(firstname="John", lastname="Doe")
32
+ ... )
33
+ """
34
+
35
+ BASE_URL = "https://api.snippe.sh/api/v1"
36
+
37
+ def __init__(
38
+ self,
39
+ api_key: str,
40
+ base_url: Optional[str] = None,
41
+ timeout: float = 30.0,
42
+ ):
43
+ """
44
+ Initialize Snippe client.
45
+
46
+ Args:
47
+ api_key: Your Snippe API key
48
+ base_url: Override the base URL (for testing)
49
+ timeout: Request timeout in seconds
50
+ """
51
+ self.api_key = api_key
52
+ self.base_url = base_url or self.BASE_URL
53
+ self._client = httpx.Client(
54
+ base_url=self.base_url,
55
+ headers={
56
+ "Authorization": f"Bearer {api_key}",
57
+ "Content-Type": "application/json",
58
+ },
59
+ timeout=timeout,
60
+ )
61
+
62
+ def _handle_response(self, response: httpx.Response) -> dict:
63
+ """Handle API response and raise appropriate exceptions."""
64
+ try:
65
+ data = response.json()
66
+ except Exception:
67
+ data = {"message": response.text}
68
+
69
+ if response.status_code == 200 or response.status_code == 201:
70
+ return data.get("data", data)
71
+
72
+ message = data.get("message", "Unknown error")
73
+ error_code = data.get("error_code", "")
74
+ code = response.status_code
75
+
76
+ if code == 401:
77
+ raise AuthenticationError(message, code, error_code)
78
+ elif code == 400:
79
+ raise ValidationError(message, code, error_code)
80
+ elif code == 404:
81
+ raise NotFoundError(message, code, error_code)
82
+ elif code == 429:
83
+ raise RateLimitError(message, code, error_code)
84
+ elif code >= 500:
85
+ raise ServerError(message, code, error_code)
86
+ else:
87
+ raise SnippeError(message, code, error_code)
88
+
89
+ def _create_payment(
90
+ self,
91
+ payment_type: PaymentType,
92
+ amount: int,
93
+ currency: Currency,
94
+ phone_number: str,
95
+ customer: Customer,
96
+ callback_url: Optional[str] = None,
97
+ webhook_url: Optional[str] = None,
98
+ metadata: Optional[dict] = None,
99
+ idempotency_key: Optional[str] = None,
100
+ ) -> Payment:
101
+ """Internal method to create a payment."""
102
+ payload = {
103
+ "payment_type": payment_type,
104
+ "details": PaymentDetails(amount, currency, callback_url).to_dict(),
105
+ "phone_number": phone_number,
106
+ "customer": customer.to_dict(),
107
+ }
108
+ if webhook_url:
109
+ payload["webhook_url"] = webhook_url
110
+ if metadata:
111
+ payload["metadata"] = metadata
112
+
113
+ headers = {}
114
+ if idempotency_key:
115
+ headers["Idempotency-Key"] = idempotency_key
116
+
117
+ response = self._client.post("/payments", json=payload, headers=headers)
118
+ data = self._handle_response(response)
119
+ return Payment.from_dict(data)
120
+
121
+ def create_mobile_payment(
122
+ self,
123
+ amount: int,
124
+ currency: Currency,
125
+ phone_number: str,
126
+ customer: Customer,
127
+ callback_url: Optional[str] = None,
128
+ webhook_url: Optional[str] = None,
129
+ metadata: Optional[dict] = None,
130
+ idempotency_key: Optional[str] = None,
131
+ ) -> Payment:
132
+ """
133
+ Create a mobile money payment (USSD push).
134
+
135
+ Customer receives a USSD prompt to confirm payment.
136
+
137
+ Args:
138
+ amount: Amount in smallest currency unit (e.g., cents)
139
+ currency: Currency code (TZS, KES, UGX)
140
+ phone_number: Customer phone number
141
+ customer: Customer information
142
+ callback_url: URL to redirect after payment
143
+ webhook_url: URL to receive payment status updates
144
+ metadata: Custom key-value pairs
145
+ idempotency_key: Unique key to prevent duplicates
146
+
147
+ Returns:
148
+ Payment object with reference and status
149
+ """
150
+ return self._create_payment(
151
+ payment_type="mobile",
152
+ amount=amount,
153
+ currency=currency,
154
+ phone_number=phone_number,
155
+ customer=customer,
156
+ callback_url=callback_url,
157
+ webhook_url=webhook_url,
158
+ metadata=metadata,
159
+ idempotency_key=idempotency_key,
160
+ )
161
+
162
+ def create_card_payment(
163
+ self,
164
+ amount: int,
165
+ currency: Currency,
166
+ phone_number: str,
167
+ customer: Customer,
168
+ callback_url: str,
169
+ webhook_url: Optional[str] = None,
170
+ metadata: Optional[dict] = None,
171
+ idempotency_key: Optional[str] = None,
172
+ ) -> Payment:
173
+ """
174
+ Create a card payment.
175
+
176
+ Returns a payment_url to redirect the customer.
177
+
178
+ Args:
179
+ amount: Amount in smallest currency unit
180
+ currency: Currency code (TZS, KES, UGX)
181
+ phone_number: Customer phone number
182
+ customer: Customer information (must include address fields)
183
+ callback_url: URL to redirect after payment (required)
184
+ webhook_url: URL to receive payment status updates
185
+ metadata: Custom key-value pairs
186
+ idempotency_key: Unique key to prevent duplicates
187
+
188
+ Returns:
189
+ Payment object with payment_url for redirect
190
+ """
191
+ return self._create_payment(
192
+ payment_type="card",
193
+ amount=amount,
194
+ currency=currency,
195
+ phone_number=phone_number,
196
+ customer=customer,
197
+ callback_url=callback_url,
198
+ webhook_url=webhook_url,
199
+ metadata=metadata,
200
+ idempotency_key=idempotency_key,
201
+ )
202
+
203
+ def create_qr_payment(
204
+ self,
205
+ amount: int,
206
+ currency: Currency,
207
+ phone_number: str,
208
+ customer: Customer,
209
+ callback_url: Optional[str] = None,
210
+ webhook_url: Optional[str] = None,
211
+ metadata: Optional[dict] = None,
212
+ idempotency_key: Optional[str] = None,
213
+ ) -> Payment:
214
+ """
215
+ Create a dynamic QR code payment.
216
+
217
+ Returns a QR code for the customer to scan.
218
+
219
+ Args:
220
+ amount: Amount in smallest currency unit
221
+ currency: Currency code (TZS, KES, UGX)
222
+ phone_number: Customer phone number
223
+ customer: Customer information
224
+ callback_url: URL to redirect after payment
225
+ webhook_url: URL to receive payment status updates
226
+ metadata: Custom key-value pairs
227
+ idempotency_key: Unique key to prevent duplicates
228
+
229
+ Returns:
230
+ Payment object with qr_code and payment_token
231
+ """
232
+ return self._create_payment(
233
+ payment_type="dynamic-qr",
234
+ amount=amount,
235
+ currency=currency,
236
+ phone_number=phone_number,
237
+ customer=customer,
238
+ callback_url=callback_url,
239
+ webhook_url=webhook_url,
240
+ metadata=metadata,
241
+ idempotency_key=idempotency_key,
242
+ )
243
+
244
+ def get_payment(self, reference: str) -> Payment:
245
+ """
246
+ Get payment status by reference.
247
+
248
+ Args:
249
+ reference: Payment reference from create response
250
+
251
+ Returns:
252
+ Payment object with current status
253
+ """
254
+ response = self._client.get(f"/payments/{reference}")
255
+ data = self._handle_response(response)
256
+ return Payment.from_dict(data)
257
+
258
+ def list_payments(
259
+ self,
260
+ limit: int = 20,
261
+ offset: int = 0,
262
+ ) -> PaymentList:
263
+ """
264
+ List all payments for your account.
265
+
266
+ Args:
267
+ limit: Results per page (max 100)
268
+ offset: Pagination offset
269
+
270
+ Returns:
271
+ PaymentList with payments and pagination info
272
+ """
273
+ response = self._client.get(
274
+ "/payments",
275
+ params={"limit": limit, "offset": offset},
276
+ )
277
+ data = self._handle_response(response)
278
+ return PaymentList.from_dict(data)
279
+
280
+ def get_balance(self) -> Balance:
281
+ """
282
+ Get your current account balance.
283
+
284
+ Returns:
285
+ Balance object with available and pending amounts
286
+ """
287
+ response = self._client.get("/payments/balance")
288
+ data = self._handle_response(response)
289
+ return Balance.from_dict(data)
290
+
291
+ def close(self) -> None:
292
+ """Close the HTTP client."""
293
+ self._client.close()
294
+
295
+ def __enter__(self) -> "Snippe":
296
+ return self
297
+
298
+ def __exit__(self, *args) -> None:
299
+ self.close()
300
+
301
+
302
+ class AsyncSnippe:
303
+ """
304
+ Async Snippe Payment API client.
305
+
306
+ Usage:
307
+ >>> from snippe import AsyncSnippe, Customer
308
+ >>> async with AsyncSnippe("your_api_key") as client:
309
+ ... payment = await client.create_mobile_payment(...)
310
+ """
311
+
312
+ BASE_URL = "https://api.snippe.sh/api/v1"
313
+
314
+ def __init__(
315
+ self,
316
+ api_key: str,
317
+ base_url: Optional[str] = None,
318
+ timeout: float = 30.0,
319
+ ):
320
+ """Initialize async Snippe client."""
321
+ self.api_key = api_key
322
+ self.base_url = base_url or self.BASE_URL
323
+ self._client = httpx.AsyncClient(
324
+ base_url=self.base_url,
325
+ headers={
326
+ "Authorization": f"Bearer {api_key}",
327
+ "Content-Type": "application/json",
328
+ },
329
+ timeout=timeout,
330
+ )
331
+
332
+ async def _handle_response(self, response: httpx.Response) -> dict:
333
+ """Handle API response and raise appropriate exceptions."""
334
+ try:
335
+ data = response.json()
336
+ except Exception:
337
+ data = {"message": response.text}
338
+
339
+ if response.status_code == 200 or response.status_code == 201:
340
+ return data.get("data", data)
341
+
342
+ message = data.get("message", "Unknown error")
343
+ error_code = data.get("error_code", "")
344
+ code = response.status_code
345
+
346
+ if code == 401:
347
+ raise AuthenticationError(message, code, error_code)
348
+ elif code == 400:
349
+ raise ValidationError(message, code, error_code)
350
+ elif code == 404:
351
+ raise NotFoundError(message, code, error_code)
352
+ elif code == 429:
353
+ raise RateLimitError(message, code, error_code)
354
+ elif code >= 500:
355
+ raise ServerError(message, code, error_code)
356
+ else:
357
+ raise SnippeError(message, code, error_code)
358
+
359
+ async def _create_payment(
360
+ self,
361
+ payment_type: PaymentType,
362
+ amount: int,
363
+ currency: Currency,
364
+ phone_number: str,
365
+ customer: Customer,
366
+ callback_url: Optional[str] = None,
367
+ webhook_url: Optional[str] = None,
368
+ metadata: Optional[dict] = None,
369
+ idempotency_key: Optional[str] = None,
370
+ ) -> Payment:
371
+ """Internal method to create a payment."""
372
+ payload = {
373
+ "payment_type": payment_type,
374
+ "details": PaymentDetails(amount, currency, callback_url).to_dict(),
375
+ "phone_number": phone_number,
376
+ "customer": customer.to_dict(),
377
+ }
378
+ if webhook_url:
379
+ payload["webhook_url"] = webhook_url
380
+ if metadata:
381
+ payload["metadata"] = metadata
382
+
383
+ headers = {}
384
+ if idempotency_key:
385
+ headers["Idempotency-Key"] = idempotency_key
386
+
387
+ response = await self._client.post("/payments", json=payload, headers=headers)
388
+ data = await self._handle_response(response)
389
+ return Payment.from_dict(data)
390
+
391
+ async def create_mobile_payment(
392
+ self,
393
+ amount: int,
394
+ currency: Currency,
395
+ phone_number: str,
396
+ customer: Customer,
397
+ callback_url: Optional[str] = None,
398
+ webhook_url: Optional[str] = None,
399
+ metadata: Optional[dict] = None,
400
+ idempotency_key: Optional[str] = None,
401
+ ) -> Payment:
402
+ """Create a mobile money payment (USSD push)."""
403
+ return await self._create_payment(
404
+ payment_type="mobile",
405
+ amount=amount,
406
+ currency=currency,
407
+ phone_number=phone_number,
408
+ customer=customer,
409
+ callback_url=callback_url,
410
+ webhook_url=webhook_url,
411
+ metadata=metadata,
412
+ idempotency_key=idempotency_key,
413
+ )
414
+
415
+ async def create_card_payment(
416
+ self,
417
+ amount: int,
418
+ currency: Currency,
419
+ phone_number: str,
420
+ customer: Customer,
421
+ callback_url: str,
422
+ webhook_url: Optional[str] = None,
423
+ metadata: Optional[dict] = None,
424
+ idempotency_key: Optional[str] = None,
425
+ ) -> Payment:
426
+ """Create a card payment."""
427
+ return await self._create_payment(
428
+ payment_type="card",
429
+ amount=amount,
430
+ currency=currency,
431
+ phone_number=phone_number,
432
+ customer=customer,
433
+ callback_url=callback_url,
434
+ webhook_url=webhook_url,
435
+ metadata=metadata,
436
+ idempotency_key=idempotency_key,
437
+ )
438
+
439
+ async def create_qr_payment(
440
+ self,
441
+ amount: int,
442
+ currency: Currency,
443
+ phone_number: str,
444
+ customer: Customer,
445
+ callback_url: Optional[str] = None,
446
+ webhook_url: Optional[str] = None,
447
+ metadata: Optional[dict] = None,
448
+ idempotency_key: Optional[str] = None,
449
+ ) -> Payment:
450
+ """Create a dynamic QR code payment."""
451
+ return await self._create_payment(
452
+ payment_type="dynamic-qr",
453
+ amount=amount,
454
+ currency=currency,
455
+ phone_number=phone_number,
456
+ customer=customer,
457
+ callback_url=callback_url,
458
+ webhook_url=webhook_url,
459
+ metadata=metadata,
460
+ idempotency_key=idempotency_key,
461
+ )
462
+
463
+ async def get_payment(self, reference: str) -> Payment:
464
+ """Get payment status by reference."""
465
+ response = await self._client.get(f"/payments/{reference}")
466
+ data = await self._handle_response(response)
467
+ return Payment.from_dict(data)
468
+
469
+ async def list_payments(
470
+ self,
471
+ limit: int = 20,
472
+ offset: int = 0,
473
+ ) -> PaymentList:
474
+ """List all payments for your account."""
475
+ response = await self._client.get(
476
+ "/payments",
477
+ params={"limit": limit, "offset": offset},
478
+ )
479
+ data = await self._handle_response(response)
480
+ return PaymentList.from_dict(data)
481
+
482
+ async def get_balance(self) -> Balance:
483
+ """Get your current account balance."""
484
+ response = await self._client.get("/payments/balance")
485
+ data = await self._handle_response(response)
486
+ return Balance.from_dict(data)
487
+
488
+ async def close(self) -> None:
489
+ """Close the HTTP client."""
490
+ await self._client.aclose()
491
+
492
+ async def __aenter__(self) -> "AsyncSnippe":
493
+ return self
494
+
495
+ async def __aexit__(self, *args) -> None:
496
+ await self.close()
snippe/exceptions.py ADDED
@@ -0,0 +1,41 @@
1
+ """Exceptions for Snippe SDK."""
2
+
3
+
4
+ class SnippeError(Exception):
5
+ """Base exception for all Snippe errors."""
6
+
7
+ def __init__(self, message: str, code: int = 0, error_code: str = ""):
8
+ self.message = message
9
+ self.code = code
10
+ self.error_code = error_code
11
+ super().__init__(message)
12
+
13
+
14
+ class AuthenticationError(SnippeError):
15
+ """Invalid or missing API key."""
16
+ pass
17
+
18
+
19
+ class ValidationError(SnippeError):
20
+ """Invalid request parameters."""
21
+ pass
22
+
23
+
24
+ class NotFoundError(SnippeError):
25
+ """Resource not found."""
26
+ pass
27
+
28
+
29
+ class RateLimitError(SnippeError):
30
+ """Too many requests."""
31
+ pass
32
+
33
+
34
+ class ServerError(SnippeError):
35
+ """Snippe server error."""
36
+ pass
37
+
38
+
39
+ class WebhookVerificationError(SnippeError):
40
+ """Invalid webhook signature."""
41
+ pass
snippe/models.py ADDED
@@ -0,0 +1,165 @@
1
+ """Data models for Snippe SDK."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Optional
6
+
7
+ from .types import Currency, PaymentStatus, PaymentType, WebhookEvent
8
+
9
+
10
+ @dataclass
11
+ class Customer:
12
+ """Customer information."""
13
+ firstname: str
14
+ lastname: str
15
+ email: Optional[str] = None
16
+ address: Optional[str] = None
17
+ city: Optional[str] = None
18
+ state: Optional[str] = None
19
+ postcode: Optional[str] = None
20
+ country: Optional[str] = None
21
+ phone: Optional[str] = None
22
+
23
+ def to_dict(self) -> dict:
24
+ """Convert to API-compatible dict."""
25
+ data = {"firstname": self.firstname, "lastname": self.lastname}
26
+ if self.email:
27
+ data["email"] = self.email
28
+ if self.address:
29
+ data["address"] = self.address
30
+ if self.city:
31
+ data["city"] = self.city
32
+ if self.state:
33
+ data["state"] = self.state
34
+ if self.postcode:
35
+ data["postcode"] = self.postcode
36
+ if self.country:
37
+ data["country"] = self.country
38
+ return data
39
+
40
+
41
+ @dataclass
42
+ class PaymentDetails:
43
+ """Payment amount details."""
44
+ amount: int
45
+ currency: Currency
46
+ callback_url: Optional[str] = None
47
+
48
+ def to_dict(self) -> dict:
49
+ """Convert to API-compatible dict."""
50
+ data = {"amount": self.amount, "currency": self.currency}
51
+ if self.callback_url:
52
+ data["callback_url"] = self.callback_url
53
+ return data
54
+
55
+
56
+ @dataclass
57
+ class Payment:
58
+ """Payment response from API."""
59
+ reference: str
60
+ status: PaymentStatus
61
+ amount: int
62
+ currency: Currency
63
+ payment_type: PaymentType
64
+ expires_at: Optional[str] = None
65
+ payment_url: Optional[str] = None
66
+ qr_code: Optional[str] = None
67
+ payment_qr_code: Optional[str] = None
68
+ payment_token: Optional[str] = None
69
+ id: Optional[str] = None
70
+ psp_reference: Optional[str] = None
71
+ fee_amount: Optional[int] = None
72
+ net_amount: Optional[int] = None
73
+ customer: Optional[dict] = None
74
+ metadata: Optional[dict] = None
75
+ created_at: Optional[str] = None
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict) -> "Payment":
79
+ """Create Payment from API response dict."""
80
+ return cls(
81
+ reference=data["reference"],
82
+ status=data["status"],
83
+ amount=data["amount"],
84
+ currency=data["currency"],
85
+ payment_type=data["payment_type"],
86
+ expires_at=data.get("expires_at"),
87
+ payment_url=data.get("payment_url"),
88
+ qr_code=data.get("qr_code"),
89
+ payment_qr_code=data.get("payment_qr_code"),
90
+ payment_token=data.get("payment_token"),
91
+ id=data.get("id"),
92
+ psp_reference=data.get("psp_reference"),
93
+ fee_amount=data.get("fee_amount"),
94
+ net_amount=data.get("net_amount"),
95
+ customer=data.get("customer"),
96
+ metadata=data.get("metadata"),
97
+ created_at=data.get("created_at"),
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class PaymentList:
103
+ """Paginated list of payments."""
104
+ payments: list[Payment]
105
+ limit: int
106
+ offset: int
107
+
108
+ @classmethod
109
+ def from_dict(cls, data: dict) -> "PaymentList":
110
+ """Create PaymentList from API response dict."""
111
+ return cls(
112
+ payments=[Payment.from_dict(p) for p in data.get("payments", [])],
113
+ limit=data.get("limit", 20),
114
+ offset=data.get("offset", 0),
115
+ )
116
+
117
+
118
+ @dataclass
119
+ class Balance:
120
+ """Account balance."""
121
+ available_balance: int
122
+ balance: int
123
+ currency: Currency
124
+
125
+ @classmethod
126
+ def from_dict(cls, data: dict) -> "Balance":
127
+ """Create Balance from API response dict."""
128
+ return cls(
129
+ available_balance=data.get("available_balance", 0),
130
+ balance=data.get("balance", 0),
131
+ currency=data.get("currency", "TZS"),
132
+ )
133
+
134
+
135
+ @dataclass
136
+ class WebhookPayload:
137
+ """Webhook event payload."""
138
+ event: WebhookEvent
139
+ reference: str
140
+ status: PaymentStatus
141
+ amount: dict
142
+ payment_channel: str
143
+ payment_fee: int
144
+ customer: dict
145
+ metadata: dict
146
+ completed_at: Optional[str]
147
+ created_at: str
148
+ timestamp: int
149
+
150
+ @classmethod
151
+ def from_dict(cls, data: dict) -> "WebhookPayload":
152
+ """Create WebhookPayload from webhook data."""
153
+ return cls(
154
+ event=data["event"],
155
+ reference=data["reference"],
156
+ status=data["status"],
157
+ amount=data["amount"],
158
+ payment_channel=data.get("payment_channel", ""),
159
+ payment_fee=data.get("payment_fee", 0),
160
+ customer=data.get("customer", {}),
161
+ metadata=data.get("metadata", {}),
162
+ completed_at=data.get("completed_at"),
163
+ created_at=data["created_at"],
164
+ timestamp=data["timestamp"],
165
+ )
snippe/py.typed ADDED
File without changes
snippe/types.py ADDED
@@ -0,0 +1,13 @@
1
+ """Type definitions for Snippe SDK."""
2
+
3
+ from typing import Literal
4
+
5
+ PaymentType = Literal["mobile", "card", "dynamic-qr"]
6
+ PaymentStatus = Literal["pending", "completed", "failed", "expired", "voided"]
7
+ Currency = Literal["TZS", "KES", "UGX"]
8
+ WebhookEvent = Literal[
9
+ "payment.completed",
10
+ "payment.failed",
11
+ "payment.expired",
12
+ "payment.voided"
13
+ ]
snippe/webhooks.py ADDED
@@ -0,0 +1,159 @@
1
+ """Webhook verification utilities for Snippe SDK."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Optional
7
+
8
+ from .exceptions import WebhookVerificationError
9
+ from .models import WebhookPayload
10
+
11
+
12
+ class WebhookHandler:
13
+ """
14
+ Webhook handler for verifying and parsing Snippe webhooks.
15
+
16
+ Usage:
17
+ >>> from snippe import WebhookHandler
18
+ >>> handler = WebhookHandler("your_webhook_signing_key")
19
+ >>> payload = handler.verify_and_parse(
20
+ ... body=request.body,
21
+ ... signature=request.headers["X-Webhook-Signature"],
22
+ ... timestamp=request.headers["X-Webhook-Timestamp"]
23
+ ... )
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ signing_key: str,
29
+ tolerance: int = 300,
30
+ ):
31
+ """
32
+ Initialize webhook handler.
33
+
34
+ Args:
35
+ signing_key: Your webhook signing key
36
+ tolerance: Max age in seconds for webhook (default: 5 minutes)
37
+ """
38
+ self.signing_key = signing_key
39
+ self.tolerance = tolerance
40
+
41
+ def compute_signature(self, payload: str, timestamp: str) -> str:
42
+ """
43
+ Compute HMAC-SHA256 signature for payload.
44
+
45
+ Args:
46
+ payload: Raw request body as string
47
+ timestamp: Unix timestamp from X-Webhook-Timestamp header
48
+
49
+ Returns:
50
+ Hex-encoded signature
51
+ """
52
+ message = f"{timestamp}.{payload}"
53
+ signature = hmac.new(
54
+ self.signing_key.encode(),
55
+ message.encode(),
56
+ hashlib.sha256,
57
+ )
58
+ return signature.hexdigest()
59
+
60
+ def verify_signature(
61
+ self,
62
+ payload: str,
63
+ signature: str,
64
+ timestamp: str,
65
+ ) -> bool:
66
+ """
67
+ Verify webhook signature.
68
+
69
+ Args:
70
+ payload: Raw request body as string
71
+ signature: Value from X-Webhook-Signature header
72
+ timestamp: Value from X-Webhook-Timestamp header
73
+
74
+ Returns:
75
+ True if signature is valid
76
+
77
+ Raises:
78
+ WebhookVerificationError: If signature is invalid or expired
79
+ """
80
+ # Check timestamp to prevent replay attacks
81
+ try:
82
+ ts = int(timestamp)
83
+ except (ValueError, TypeError):
84
+ raise WebhookVerificationError("Invalid timestamp")
85
+
86
+ if abs(time.time() - ts) > self.tolerance:
87
+ raise WebhookVerificationError("Webhook timestamp expired")
88
+
89
+ # Compute and compare signatures
90
+ expected = self.compute_signature(payload, timestamp)
91
+ if not hmac.compare_digest(expected, signature):
92
+ raise WebhookVerificationError("Invalid signature")
93
+
94
+ return True
95
+
96
+ def parse(self, data: dict) -> WebhookPayload:
97
+ """
98
+ Parse webhook payload without verification.
99
+
100
+ Args:
101
+ data: Parsed JSON payload
102
+
103
+ Returns:
104
+ WebhookPayload object
105
+ """
106
+ return WebhookPayload.from_dict(data)
107
+
108
+ def verify_and_parse(
109
+ self,
110
+ body: str,
111
+ signature: str,
112
+ timestamp: str,
113
+ ) -> WebhookPayload:
114
+ """
115
+ Verify signature and parse webhook payload.
116
+
117
+ Args:
118
+ body: Raw request body as string
119
+ signature: Value from X-Webhook-Signature header
120
+ timestamp: Value from X-Webhook-Timestamp header
121
+
122
+ Returns:
123
+ WebhookPayload object
124
+
125
+ Raises:
126
+ WebhookVerificationError: If signature is invalid or expired
127
+ """
128
+ import json
129
+
130
+ self.verify_signature(body, signature, timestamp)
131
+ data = json.loads(body)
132
+ return self.parse(data)
133
+
134
+
135
+ def verify_webhook(
136
+ body: str,
137
+ signature: str,
138
+ timestamp: str,
139
+ signing_key: str,
140
+ tolerance: int = 300,
141
+ ) -> WebhookPayload:
142
+ """
143
+ Convenience function to verify and parse a webhook.
144
+
145
+ Args:
146
+ body: Raw request body as string
147
+ signature: Value from X-Webhook-Signature header
148
+ timestamp: Value from X-Webhook-Timestamp header
149
+ signing_key: Your webhook signing key
150
+ tolerance: Max age in seconds (default: 5 minutes)
151
+
152
+ Returns:
153
+ WebhookPayload object
154
+
155
+ Raises:
156
+ WebhookVerificationError: If signature is invalid or expired
157
+ """
158
+ handler = WebhookHandler(signing_key, tolerance)
159
+ return handler.verify_and_parse(body, signature, timestamp)
@@ -0,0 +1,249 @@
1
+ Metadata-Version: 2.4
2
+ Name: snippe
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Snippe Payment API
5
+ Project-URL: Homepage, https://snippe.sh
6
+ Project-URL: Documentation, https://documenter.getpostman.com/view/36488510/2sBXViiWAV#6beb6d54-34a1-4c9c-8a19-acd92a865711
7
+ Project-URL: Repository, https://github.com/Neurotech-HQ/snippe-python-sdk
8
+ Author-email: Nassdaq <mwaijegakelvin@gmail.com>
9
+ License-Expression: MIT
10
+ Keywords: Tanzania Payment,africa,fintech,mobile-money,snippe
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.24.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
25
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
26
+ Requires-Dist: respx>=0.20.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Snippe Python SDK
30
+
31
+ Official Python SDK for [Snippe Payment API](https://snippe.sh) - Accept payments via mobile money, card, and QR code in East Africa.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install snippe
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```python
42
+ from snippe import Snippe, Customer
43
+
44
+ client = Snippe("your_api_key")
45
+
46
+ # Create a mobile money payment
47
+ payment = client.create_mobile_payment(
48
+ amount=1000,
49
+ currency="TZS",
50
+ phone_number="0788500000",
51
+ customer=Customer(firstname="John", lastname="Doe"),
52
+ )
53
+
54
+ print(f"Payment reference: {payment.reference}")
55
+ print(f"Status: {payment.status}")
56
+ ```
57
+
58
+ ## Payment Types
59
+
60
+ ### Mobile Money (USSD Push)
61
+
62
+ Customer receives a USSD prompt on their phone to confirm payment.
63
+
64
+ ```python
65
+ payment = client.create_mobile_payment(
66
+ amount=5000,
67
+ currency="TZS",
68
+ phone_number="0712345678",
69
+ customer=Customer(
70
+ firstname="Jane",
71
+ lastname="Doe",
72
+ email="jane@example.com" # optional
73
+ ),
74
+ webhook_url="https://yourapp.com/webhooks", # optional
75
+ metadata={"order_id": "ORD-123"}, # optional
76
+ )
77
+ ```
78
+
79
+ ### Card Payment
80
+
81
+ Returns a `payment_url` to redirect the customer to complete payment.
82
+
83
+ ```python
84
+ payment = client.create_card_payment(
85
+ amount=50000,
86
+ currency="TZS",
87
+ phone_number="0712345678",
88
+ customer=Customer(
89
+ firstname="John",
90
+ lastname="Doe",
91
+ email="john@example.com",
92
+ address="123 Main Street",
93
+ city="Dar es Salaam",
94
+ state="DSM",
95
+ postcode="14101",
96
+ country="TZ",
97
+ ),
98
+ callback_url="https://yourapp.com/callback", # required for card
99
+ webhook_url="https://yourapp.com/webhooks",
100
+ )
101
+
102
+ # Redirect customer to this URL
103
+ print(payment.payment_url)
104
+ ```
105
+
106
+ ### QR Code Payment
107
+
108
+ Returns a QR code for the customer to scan.
109
+
110
+ ```python
111
+ payment = client.create_qr_payment(
112
+ amount=25000,
113
+ currency="TZS",
114
+ phone_number="0712345678",
115
+ customer=Customer(firstname="John", lastname="Doe"),
116
+ )
117
+
118
+ # Display this QR code to customer
119
+ print(payment.payment_qr_code)
120
+ print(payment.payment_token)
121
+ ```
122
+
123
+ ## Check Payment Status
124
+
125
+ ```python
126
+ payment = client.get_payment("payment_reference")
127
+ print(f"Status: {payment.status}") # pending, completed, failed, expired, voided
128
+ ```
129
+
130
+ ## List Payments
131
+
132
+ ```python
133
+ result = client.list_payments(limit=20, offset=0)
134
+ for payment in result.payments:
135
+ print(f"{payment.reference}: {payment.status}")
136
+ ```
137
+
138
+ ## Check Balance
139
+
140
+ ```python
141
+ balance = client.get_balance()
142
+ print(f"Available: {balance.available_balance} {balance.currency}")
143
+ ```
144
+
145
+ ## Webhooks
146
+
147
+ Verify and parse webhook events from Snippe.
148
+
149
+ ```python
150
+ from snippe import verify_webhook, WebhookVerificationError
151
+
152
+ # In your webhook endpoint
153
+ try:
154
+ payload = verify_webhook(
155
+ body=request.body.decode(),
156
+ signature=request.headers["X-Webhook-Signature"],
157
+ timestamp=request.headers["X-Webhook-Timestamp"],
158
+ signing_key="your_webhook_signing_key",
159
+ )
160
+
161
+ if payload.event == "payment.completed":
162
+ print(f"Payment {payload.reference} completed!")
163
+ # Fulfill the order
164
+ elif payload.event == "payment.failed":
165
+ print(f"Payment {payload.reference} failed")
166
+ # Notify customer
167
+
168
+ except WebhookVerificationError as e:
169
+ print(f"Invalid webhook: {e}")
170
+ ```
171
+
172
+ ### Webhook Events
173
+
174
+ | Event | Description |
175
+ |-------|-------------|
176
+ | `payment.completed` | Payment successful |
177
+ | `payment.failed` | Payment declined or failed |
178
+ | `payment.expired` | Payment timed out |
179
+ | `payment.voided` | Payment cancelled |
180
+
181
+ ## Async Support
182
+
183
+ For async applications (FastAPI, aiohttp, etc.):
184
+
185
+ ```python
186
+ from snippe import AsyncSnippe, Customer
187
+
188
+ async def create_payment():
189
+ async with AsyncSnippe("your_api_key") as client:
190
+ payment = await client.create_mobile_payment(
191
+ amount=1000,
192
+ currency="TZS",
193
+ phone_number="0788500000",
194
+ customer=Customer(firstname="John", lastname="Doe"),
195
+ )
196
+ return payment
197
+ ```
198
+
199
+ ## Idempotency
200
+
201
+ Prevent duplicate payments by providing an idempotency key:
202
+
203
+ ```python
204
+ payment = client.create_mobile_payment(
205
+ amount=1000,
206
+ currency="TZS",
207
+ phone_number="0788500000",
208
+ customer=Customer(firstname="John", lastname="Doe"),
209
+ idempotency_key="unique_order_id_123", # Your unique identifier
210
+ )
211
+ ```
212
+
213
+ ## Error Handling
214
+
215
+ ```python
216
+ from snippe import (
217
+ Snippe,
218
+ AuthenticationError,
219
+ ValidationError,
220
+ NotFoundError,
221
+ RateLimitError,
222
+ ServerError,
223
+ )
224
+
225
+ try:
226
+ payment = client.create_mobile_payment(...)
227
+ except AuthenticationError:
228
+ print("Invalid API key")
229
+ except ValidationError as e:
230
+ print(f"Invalid request: {e.message}")
231
+ except NotFoundError:
232
+ print("Payment not found")
233
+ except RateLimitError:
234
+ print("Too many requests, slow down")
235
+ except ServerError:
236
+ print("Snippe server error, try again later")
237
+ ```
238
+
239
+ ## Supported Currencies
240
+
241
+ | Currency | Country |
242
+ |----------|---------|
243
+ | TZS | Tanzania |
244
+ | KES | Kenya |
245
+ | UGX | Uganda |
246
+
247
+ ## License
248
+
249
+ MIT
@@ -0,0 +1,10 @@
1
+ snippe/__init__.py,sha256=kBzciBjF9_R2aM2Mof5Z6EX9iUUw8VTOTREm3srLa90,1440
2
+ snippe/client.py,sha256=JmWbYm4tzUDea2cfcZygjsJUooQ_Tejtvn0ZsOZZKNk,15535
3
+ snippe/exceptions.py,sha256=N444s_aLPnElQVU9EqSmXskvegB72B221i0E1wuAN-8,807
4
+ snippe/models.py,sha256=cDfUNaFWtqvfsJnlKgwvtHU31h8tZrypC_JVL-YJtq4,4852
5
+ snippe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ snippe/types.py,sha256=RN-Gvr3hGFlhrmjTftcN3nw8G6EBaD9mMZbd_nkKM9Q,358
7
+ snippe/webhooks.py,sha256=OVhEcoWeM3A7arqPQl0JuCshTlgIuADD7cysVghSqR0,4352
8
+ snippe-0.1.0.dist-info/METADATA,sha256=4HPXC8s3hL6zo_1YCi2_HhEFSD0OJ2X-4cbwWV1EsGY,6203
9
+ snippe-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ snippe-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any