reevit 0.9.1__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.
reevit/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from ._version import __version__
2
+ from .client import Reevit, ReevitAPIError
3
+ from .webhooks import sign_webhook_payload, verify_webhook_signature
4
+
5
+ __all__ = [
6
+ "Reevit",
7
+ "ReevitAPIError",
8
+ "verify_webhook_signature",
9
+ "sign_webhook_payload",
10
+ "__version__",
11
+ ]
reevit/_version.py ADDED
@@ -0,0 +1,6 @@
1
+ # Single source of truth for the SDK version.
2
+ # Read by setup.py (via exec, without importing the package) and by
3
+ # client.py for the X-Reevit-Client-Version header. Keep this file
4
+ # free of imports so it stays loadable in build environments where
5
+ # runtime dependencies are not installed.
6
+ __version__ = "0.9.1"
reevit/client.py ADDED
@@ -0,0 +1,92 @@
1
+ import requests
2
+ import warnings
3
+ from typing import Optional, Dict, Any
4
+ from ._version import __version__
5
+ from .services.payments import PaymentsService
6
+ from .services.connections import ConnectionsService
7
+ from .services.subscriptions import SubscriptionsService
8
+ from .services.fraud import FraudService
9
+ from .services.customers import CustomersService
10
+ from .services.payment_links import PaymentLinksService
11
+ from .services.checkout_sessions import CheckoutSessionsService
12
+ from .services.webhooks import WebhooksService
13
+ from .services.routing_rules import RoutingRulesService
14
+ from .services.invoices import InvoicesService
15
+
16
+ API_BASE_URL_PRODUCTION = 'https://api.reevit.io'
17
+ DEFAULT_TIMEOUT = 30
18
+
19
+ class ReevitAPIError(Exception):
20
+ def __init__(self, status_code: int, message: str, code: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
21
+ self.status_code = status_code
22
+ self.code = code or "api_error"
23
+ self.details = details or {}
24
+ super().__init__(message)
25
+
26
+ def is_sandbox_key(api_key: str) -> bool:
27
+ return api_key.startswith('pfk_test_')
28
+
29
+ class Reevit:
30
+ def __init__(self, api_key: str, org_id: Optional[str] = None, base_url: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT):
31
+ if base_url is None:
32
+ base_url = API_BASE_URL_PRODUCTION
33
+ self.session = requests.Session()
34
+ self.session.headers.update({
35
+ "Content-Type": "application/json",
36
+ "User-Agent": "@reevit/python",
37
+ "X-Reevit-Key": api_key,
38
+ "X-Reevit-Client": "@reevit/python",
39
+ "X-Reevit-Client-Version": __version__,
40
+ })
41
+ if org_id:
42
+ self.session.headers["X-Org-Id"] = org_id
43
+ self.base_url = base_url.rstrip("/")
44
+ self.org_id = org_id
45
+ self.timeout = timeout
46
+
47
+ self.payments = PaymentsService(self)
48
+ self.connections = ConnectionsService(self)
49
+ self.subscriptions = SubscriptionsService(self)
50
+ self.fraud = FraudService(self)
51
+ self.customers = CustomersService(self)
52
+ self.payment_links = PaymentLinksService(self)
53
+ self.checkout_sessions = CheckoutSessionsService(self)
54
+ self.webhooks = WebhooksService(self)
55
+ self.routing_rules = RoutingRulesService(self)
56
+ self.invoices = InvoicesService(self)
57
+
58
+ def request(self, method: str, path: str, **kwargs) -> Any:
59
+ if not path.startswith("/v1/pay/") and not self.org_id:
60
+ warnings.warn(
61
+ "org_id should be provided for authenticated Reevit API requests; org-less usage is deprecated.",
62
+ DeprecationWarning,
63
+ stacklevel=2,
64
+ )
65
+ url = f"{self.base_url}{path}"
66
+ if 'timeout' not in kwargs:
67
+ kwargs['timeout'] = self.timeout
68
+ headers = kwargs.pop('headers', None)
69
+ if headers:
70
+ merged_headers = dict(self.session.headers)
71
+ merged_headers.update(headers)
72
+ kwargs['headers'] = merged_headers
73
+ response = self.session.request(method, url, **kwargs)
74
+ if response.status_code == 204:
75
+ return None
76
+
77
+ if not response.ok:
78
+ try:
79
+ payload = response.json()
80
+ except ValueError:
81
+ payload = {}
82
+ raise ReevitAPIError(
83
+ response.status_code,
84
+ payload.get("message") or response.text or "request failed",
85
+ payload.get("code"),
86
+ payload.get("details"),
87
+ )
88
+
89
+ try:
90
+ return response.json()
91
+ except ValueError:
92
+ return None
@@ -0,0 +1,2 @@
1
+ # Regular package marker: without this, setuptools' find_packages() treats
2
+ # reevit.services as a namespace package and omits it from built distributions.
@@ -0,0 +1,10 @@
1
+ from typing import Dict, Any, Optional
2
+
3
+
4
+ class CheckoutSessionsService:
5
+ def __init__(self, client):
6
+ self.client = client
7
+
8
+ def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
9
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
10
+ return self.client.request("POST", "/v1/checkout/sessions", json=data, headers=headers)
@@ -0,0 +1,55 @@
1
+ from typing import List, Dict, Any, Optional
2
+
3
+ class ConnectionsService:
4
+ def __init__(self, client):
5
+ self.client = client
6
+
7
+ def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
8
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
9
+ return self.client.request("POST", "/v1/connections", json=data, headers=headers)
10
+
11
+ def list(self, **params: Any) -> List[Dict[str, Any]]:
12
+ response = self.client.request("GET", "/v1/connections", params=params)
13
+ if isinstance(response, dict):
14
+ return response.get("connections", [])
15
+ return response
16
+
17
+ def get(self, connection_id: str) -> Dict[str, Any]:
18
+ return self.client.request("GET", f"/v1/connections/{connection_id}")
19
+
20
+ def delete(self, connection_id: str, idempotency_key: Optional[str] = None) -> None:
21
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
22
+ self.client.request("DELETE", f"/v1/connections/{connection_id}", headers=headers)
23
+
24
+ def validate(self, connection_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
25
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
26
+ return self.client.request("POST", f"/v1/connections/{connection_id}/validate", headers=headers)
27
+
28
+ def list_audit(self, connection_id: str, **params: Any) -> List[Dict[str, Any]]:
29
+ response = self.client.request("GET", f"/v1/connections/{connection_id}/audit", params=params)
30
+ if isinstance(response, dict):
31
+ return response.get("audit", [])
32
+ return response
33
+
34
+ def update_labels(self, connection_id: str, labels: List[str], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
35
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
36
+ return self.client.request(
37
+ "PATCH",
38
+ f"/v1/connections/{connection_id}/labels",
39
+ json={"labels": labels},
40
+ headers=headers,
41
+ )
42
+
43
+ def update_status(self, connection_id: str, status: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
44
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
45
+ return self.client.request(
46
+ "PATCH",
47
+ f"/v1/connections/{connection_id}/status",
48
+ json={"status": status},
49
+ headers=headers,
50
+ )
51
+
52
+ def test(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> bool:
53
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
54
+ resp = self.client.request("POST", "/v1/connections/test", json=data, headers=headers)
55
+ return resp.get("success", False)
@@ -0,0 +1,39 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+
4
+ def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
5
+ if isinstance(payload, dict):
6
+ return payload.get(key, [])
7
+ return payload or []
8
+
9
+
10
+ class CustomersService:
11
+ def __init__(self, client):
12
+ self.client = client
13
+
14
+ def list(self, **params: Any) -> List[Dict[str, Any]]:
15
+ return _extract_list(self.client.request("GET", "/v1/customers", params=params), "customers")
16
+
17
+ def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
18
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
19
+ return self.client.request("POST", "/v1/customers", json=data, headers=headers)
20
+
21
+ def get(self, customer_id: str) -> Dict[str, Any]:
22
+ return self.client.request("GET", f"/v1/customers/{customer_id}")
23
+
24
+ def update(self, customer_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
25
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
26
+ return self.client.request("PATCH", f"/v1/customers/{customer_id}", json=data, headers=headers)
27
+
28
+ def delete(self, customer_id: str, idempotency_key: Optional[str] = None) -> None:
29
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
30
+ self.client.request("DELETE", f"/v1/customers/{customer_id}", headers=headers)
31
+
32
+ def lookup(self, external_id: str) -> Dict[str, Any]:
33
+ return self.client.request("GET", "/v1/customers/lookup", params={"external_id": external_id})
34
+
35
+ def top(self, **params: Any) -> List[Dict[str, Any]]:
36
+ return _extract_list(self.client.request("GET", "/v1/customers/top", params=params), "customers")
37
+
38
+ def payment_history(self, customer_id: str, **params: Any) -> List[Dict[str, Any]]:
39
+ return _extract_list(self.client.request("GET", f"/v1/customers/{customer_id}/payments", params=params), "payments")
@@ -0,0 +1,11 @@
1
+ from typing import Dict, Any
2
+
3
+ class FraudService:
4
+ def __init__(self, client):
5
+ self.client = client
6
+
7
+ def get(self) -> Dict[str, Any]:
8
+ return self.client.request("GET", "/v1/policies/fraud")
9
+
10
+ def update(self, policy: Dict[str, Any]) -> Dict[str, Any]:
11
+ return self.client.request("POST", "/v1/policies/fraud", json=policy)
@@ -0,0 +1,30 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+
4
+ def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
5
+ if isinstance(payload, dict):
6
+ return payload.get(key, [])
7
+ return payload or []
8
+
9
+
10
+ class InvoicesService:
11
+ def __init__(self, client):
12
+ self.client = client
13
+
14
+ def list(self, **params: Any) -> List[Dict[str, Any]]:
15
+ return _extract_list(self.client.request("GET", "/v1/invoices", params=params), "invoices")
16
+
17
+ def get(self, invoice_id: str) -> Dict[str, Any]:
18
+ return self.client.request("GET", f"/v1/invoices/{invoice_id}")
19
+
20
+ def update(self, invoice_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
21
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
22
+ return self.client.request("PATCH", f"/v1/invoices/{invoice_id}", json=data, headers=headers)
23
+
24
+ def cancel(self, invoice_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
25
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
26
+ return self.client.request("POST", f"/v1/invoices/{invoice_id}/cancel", json={}, headers=headers)
27
+
28
+ def retry(self, invoice_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
29
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
30
+ return self.client.request("POST", f"/v1/invoices/{invoice_id}/retry", json={}, headers=headers)
@@ -0,0 +1,42 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+
4
+ def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
5
+ if isinstance(payload, dict):
6
+ return payload.get(key, [])
7
+ return payload or []
8
+
9
+
10
+ class PaymentLinksService:
11
+ def __init__(self, client):
12
+ self.client = client
13
+
14
+ def list(self, **params: Any) -> List[Dict[str, Any]]:
15
+ return _extract_list(self.client.request("GET", "/v1/payment-links", params=params), "payment_links")
16
+
17
+ def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
18
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
19
+ return self.client.request("POST", "/v1/payment-links", json=data, headers=headers)
20
+
21
+ def get(self, payment_link_id: str) -> Dict[str, Any]:
22
+ return self.client.request("GET", f"/v1/payment-links/{payment_link_id}")
23
+
24
+ def update(self, payment_link_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
25
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
26
+ return self.client.request("PATCH", f"/v1/payment-links/{payment_link_id}", json=data, headers=headers)
27
+
28
+ def delete(self, payment_link_id: str, idempotency_key: Optional[str] = None) -> None:
29
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
30
+ self.client.request("DELETE", f"/v1/payment-links/{payment_link_id}", headers=headers)
31
+
32
+ def get_stats(self, payment_link_id: str) -> Dict[str, Any]:
33
+ return self.client.request("GET", f"/v1/payment-links/{payment_link_id}/stats")
34
+
35
+ def list_payments(self, payment_link_id: str, **params: Any) -> List[Dict[str, Any]]:
36
+ return _extract_list(
37
+ self.client.request("GET", f"/v1/payment-links/{payment_link_id}/payments", params=params),
38
+ "payments",
39
+ )
40
+
41
+ def get_by_code(self, code: str) -> Dict[str, Any]:
42
+ return self.client.request("GET", f"/v1/pay/{code}")
@@ -0,0 +1,54 @@
1
+ from typing import List, Dict, Any, Optional
2
+
3
+ class PaymentsService:
4
+ def __init__(self, client):
5
+ self.client = client
6
+
7
+ def create_intent(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
8
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
9
+ return self.client.request("POST", "/v1/payments/intents", json=data, headers=headers)
10
+
11
+ def list(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
12
+ params = {"limit": limit, "offset": offset}
13
+ return self.client.request("GET", "/v1/payments", params=params)
14
+
15
+ def get(self, payment_id: str) -> Dict[str, Any]:
16
+ return self.client.request("GET", f"/v1/payments/{payment_id}")
17
+
18
+ def update_intent(self, payment_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
19
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
20
+ return self.client.request("PATCH", f"/v1/payments/intents/{payment_id}", json=data, headers=headers)
21
+
22
+ def confirm(self, payment_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
23
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
24
+ return self.client.request("POST", f"/v1/payments/{payment_id}/confirm", json={}, headers=headers)
25
+
26
+ def confirm_intent(self, payment_id: str, client_secret: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
27
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
28
+ return self.client.request(
29
+ "POST",
30
+ f"/v1/payments/{payment_id}/confirm-intent",
31
+ params={"client_secret": client_secret},
32
+ json={},
33
+ headers=headers,
34
+ )
35
+
36
+ def cancel(self, payment_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
37
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
38
+ return self.client.request("POST", f"/v1/payments/{payment_id}/cancel", json={}, headers=headers)
39
+
40
+ def retry(self, payment_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
41
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
42
+ return self.client.request("POST", f"/v1/payments/{payment_id}/retry", json={}, headers=headers)
43
+
44
+ def refund(self, payment_id: str, amount: Optional[int] = None, reason: Optional[str] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
45
+ data = {}
46
+ if amount is not None:
47
+ data["amount"] = amount
48
+ if reason is not None:
49
+ data["reason"] = reason
50
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
51
+ return self.client.request("POST", f"/v1/payments/{payment_id}/refund", json=data, headers=headers)
52
+
53
+ def stats(self, **params: Any) -> Dict[str, Any]:
54
+ return self.client.request("GET", "/v1/payments/stats", params=params)
@@ -0,0 +1,30 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+
4
+ def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
5
+ if isinstance(payload, dict):
6
+ return payload.get(key, [])
7
+ return payload or []
8
+
9
+
10
+ class RoutingRulesService:
11
+ def __init__(self, client):
12
+ self.client = client
13
+
14
+ def list(self) -> List[Dict[str, Any]]:
15
+ return _extract_list(self.client.request("GET", "/v1/routing-rules"), "rules")
16
+
17
+ def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
18
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
19
+ return self.client.request("POST", "/v1/routing-rules", json=data, headers=headers)
20
+
21
+ def get(self, rule_id: str) -> Dict[str, Any]:
22
+ return self.client.request("GET", f"/v1/routing-rules/{rule_id}")
23
+
24
+ def update(self, rule_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
25
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
26
+ return self.client.request("PATCH", f"/v1/routing-rules/{rule_id}", json=data, headers=headers)
27
+
28
+ def delete(self, rule_id: str, idempotency_key: Optional[str] = None) -> None:
29
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
30
+ self.client.request("DELETE", f"/v1/routing-rules/{rule_id}", headers=headers)
@@ -0,0 +1,27 @@
1
+ from typing import List, Dict, Any, Optional
2
+
3
+ class SubscriptionsService:
4
+ def __init__(self, client):
5
+ self.client = client
6
+
7
+ def create(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
8
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
9
+ return self.client.request("POST", "/v1/subscriptions", json=data, headers=headers)
10
+
11
+ def list(self, **params: Any) -> List[Dict[str, Any]]:
12
+ return self.client.request("GET", "/v1/subscriptions", params=params)
13
+
14
+ def get(self, subscription_id: str) -> Dict[str, Any]:
15
+ return self.client.request("GET", f"/v1/subscriptions/{subscription_id}")
16
+
17
+ def update(self, subscription_id: str, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
18
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
19
+ return self.client.request("PATCH", f"/v1/subscriptions/{subscription_id}", json=data, headers=headers)
20
+
21
+ def cancel(self, subscription_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
22
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
23
+ return self.client.request("POST", f"/v1/subscriptions/{subscription_id}/cancel", json={}, headers=headers)
24
+
25
+ def resume(self, subscription_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
26
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
27
+ return self.client.request("POST", f"/v1/subscriptions/{subscription_id}/resume", json={}, headers=headers)
@@ -0,0 +1,43 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+
4
+ def _extract_list(payload: Any, key: str) -> List[Dict[str, Any]]:
5
+ if isinstance(payload, dict):
6
+ return payload.get(key, [])
7
+ return payload or []
8
+
9
+
10
+ class WebhooksService:
11
+ def __init__(self, client):
12
+ self.client = client
13
+
14
+ def get_config(self) -> Dict[str, Any]:
15
+ return self.client.request("GET", "/v1/webhooks/config")
16
+
17
+ def upsert_config(self, data: Dict[str, Any], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
18
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
19
+ return self.client.request("POST", "/v1/webhooks/config", json=data, headers=headers)
20
+
21
+ def delete_config(self, idempotency_key: Optional[str] = None) -> None:
22
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
23
+ self.client.request("DELETE", "/v1/webhooks/config", headers=headers)
24
+
25
+ def send_test(self, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
26
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
27
+ return self.client.request("POST", "/v1/webhooks/test", json={}, headers=headers)
28
+
29
+ def list_events(self, **params: Any) -> List[Dict[str, Any]]:
30
+ return _extract_list(self.client.request("GET", "/v1/webhooks/events", params=params), "events")
31
+
32
+ def get_event(self, event_id: str) -> Dict[str, Any]:
33
+ return self.client.request("GET", f"/v1/webhooks/events/{event_id}")
34
+
35
+ def replay_event(self, event_id: str, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
36
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
37
+ return self.client.request("POST", f"/v1/webhooks/events/{event_id}/replay", json={}, headers=headers)
38
+
39
+ def list_outbound(self, **params: Any) -> List[Dict[str, Any]]:
40
+ return _extract_list(self.client.request("GET", "/v1/webhooks/outbound", params=params), "outbound")
41
+
42
+ def get_outbound(self, outbound_id: str) -> Dict[str, Any]:
43
+ return self.client.request("GET", f"/v1/webhooks/outbound/{outbound_id}")
reevit/webhooks.py ADDED
@@ -0,0 +1,53 @@
1
+ """Webhook signature verification for Reevit outbound webhooks.
2
+
3
+ Reevit signs every outbound webhook with HMAC-SHA256 over the **raw request
4
+ body** and sends the result in the ``X-Reevit-Signature`` header as
5
+ ``sha256=<hex>``. The signed body includes a ``signature_timestamp`` field, so
6
+ the signature also covers the timestamp (tamper protection). To additionally
7
+ guard against replay of a captured-but-recent delivery, check that
8
+ ``signature_timestamp`` is recent after the signature verifies.
9
+
10
+ IMPORTANT: verify against the exact bytes you received. Do not ``json.loads``
11
+ and re-serialize the body first -- key order and whitespace must match what
12
+ Reevit signed, or the signature will not match.
13
+ """
14
+
15
+ import hashlib
16
+ import hmac
17
+ from typing import Optional, Union
18
+
19
+ _SIGNATURE_PREFIX = "sha256="
20
+
21
+
22
+ def _to_bytes(payload: Union[str, bytes]) -> bytes:
23
+ return payload.encode("utf-8") if isinstance(payload, str) else payload
24
+
25
+
26
+ def sign_webhook_payload(payload: Union[str, bytes], secret: str) -> str:
27
+ """Compute the ``X-Reevit-Signature`` header value for a raw webhook body.
28
+
29
+ Returns ``sha256=<hex HMAC-SHA256 of the body>``. Primarily useful for tests.
30
+ """
31
+ digest = hmac.new(secret.encode("utf-8"), _to_bytes(payload), hashlib.sha256).hexdigest()
32
+ return _SIGNATURE_PREFIX + digest
33
+
34
+
35
+ def verify_webhook_signature(
36
+ payload: Union[str, bytes],
37
+ signature: Optional[str],
38
+ secret: str,
39
+ ) -> bool:
40
+ """Verify a Reevit webhook signature in constant time.
41
+
42
+ :param payload: Raw request body, exactly as received (str or bytes).
43
+ :param signature: The ``X-Reevit-Signature`` header value (``sha256=...``).
44
+ :param secret: The signing secret for the webhook endpoint.
45
+ :returns: ``True`` only if the signature is present and valid.
46
+ """
47
+ if not signature or not secret:
48
+ return False
49
+
50
+ expected = sign_webhook_payload(payload, secret)
51
+
52
+ # compare_digest is constant-time and length-safe.
53
+ return hmac.compare_digest(expected, signature)
@@ -0,0 +1,452 @@
1
+ Metadata-Version: 2.4
2
+ Name: reevit
3
+ Version: 0.9.1
4
+ Summary: Reevit Python SDK
5
+ Home-page: https://github.com/Reevit-Platform/python-sdk
6
+ Author: Reevit
7
+ License: MIT
8
+ Project-URL: Documentation, https://docs.reevit.io
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.6
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: requests>=2.25.0
16
+ Dynamic: author
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license
22
+ Dynamic: license-file
23
+ Dynamic: project-url
24
+ Dynamic: requires-dist
25
+ Dynamic: requires-python
26
+ Dynamic: summary
27
+
28
+ # Reevit Python SDK
29
+
30
+ The official Python SDK for [Reevit](https://reevit.io) — a unified payment orchestration platform for Africa.
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/reevit.svg)](https://pypi.org/project/reevit/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/reevit.svg)](https://pypi.org/project/reevit/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install reevit==0.9.1
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from reevit import Reevit
46
+
47
+ client = Reevit(api_key="pfk_live_xxx", org_id="org_123")
48
+
49
+ # Create a payment
50
+ try:
51
+ payment = client.payments.create_intent({
52
+ "amount": 5000, # 50.00 GHS
53
+ "currency": "GHS",
54
+ "method": "momo",
55
+ "country": "GH",
56
+ "customer_id": "cust_123",
57
+ "metadata": {
58
+ "order_id": "12345"
59
+ }
60
+ }, idempotency_key="order_12345")
61
+ print(f"Payment created: {payment['id']}")
62
+ except Exception as e:
63
+ print(f"Error: {e}")
64
+
65
+ # List payments
66
+ payments = client.payments.list()
67
+ print(payments)
68
+ ```
69
+
70
+ ## Server-created checkout sessions
71
+
72
+ Create checkout sessions on your server, then pass `session["session_secret"]` to a browser SDK — [`@reevit/react`](https://www.npmjs.com/package/@reevit/react), [`@reevit/vue`](https://www.npmjs.com/package/@reevit/vue), or [`@reevit/svelte`](https://www.npmjs.com/package/@reevit/svelte) — to render the checkout UI.
73
+
74
+ ```python
75
+ session = client.checkout_sessions.create(
76
+ {
77
+ "amount": 5000,
78
+ "currency": "GHS",
79
+ "method": "mobile_money",
80
+ "country": "GH",
81
+ },
82
+ idempotency_key="order_12345",
83
+ )
84
+ ```
85
+
86
+ ## Idempotency
87
+
88
+ Pass `idempotency_key` to safely retry intent creation without duplicates.
89
+
90
+ ```python
91
+ payment = client.payments.create_intent(
92
+ {
93
+ "amount": 5000,
94
+ "currency": "GHS",
95
+ "method": "momo",
96
+ "country": "GH",
97
+ },
98
+ idempotency_key="order_12345",
99
+ )
100
+ ```
101
+
102
+ ## Features
103
+
104
+ - **Payments**: Create intents, update intents, confirm, confirm intent, cancel, retry, refund, stats
105
+ - **Connections**: Manage PSP integrations, validation, labels, status, audit
106
+ - **Subscriptions**: Manage recurring billing lifecycle
107
+ - **Fraud**: Configure fraud rules
108
+ - **Customers / Payment Links / Checkout Sessions / Webhooks / Routing Rules / Invoices**: Additional backend services
109
+
110
+ `org_id` is supported directly on the client. Omitting it for authenticated requests still works for backward compatibility, but that mode is deprecated.
111
+
112
+ ---
113
+
114
+ ## Webhook Verification
115
+
116
+ Reevit sends webhooks to notify your application of payment events. Always verify webhook signatures.
117
+
118
+ ### Understanding Webhooks
119
+
120
+ There are **two types of webhooks** in Reevit:
121
+
122
+ 1. **Inbound Webhooks (PSP → Reevit)**: Webhooks from payment providers (Paystack, Flutterwave, etc.) to Reevit. Configure these in the PSP dashboard. Reevit handles them automatically.
123
+
124
+ 2. **Outbound Webhooks (Reevit → Your App)**: Webhooks from Reevit to your application. Configure in Reevit Dashboard and create a handler in your app.
125
+
126
+ ### Signature Format
127
+
128
+ - **Header**: `X-Reevit-Signature: sha256=<hex-signature>`
129
+ - **Signature**: `HMAC-SHA256(request_body, signing_secret)`
130
+
131
+ ### Getting Your Signing Secret
132
+
133
+ 1. Go to **Reevit Dashboard > Developers > Webhooks**
134
+ 2. Configure your webhook endpoint URL
135
+ 3. Copy the signing secret (starts with `whsec_`)
136
+ 4. Set environment variable: `REEVIT_WEBHOOK_SECRET=whsec_xxx...`
137
+
138
+ ### Flask Webhook Handler
139
+
140
+ The SDK ships a constant-time verifier — `verify_webhook_signature(payload, signature, secret)` — so you do not have to reimplement HMAC. Pass the **raw** request body (not parsed-and-reserialized JSON), the `X-Reevit-Signature` header, and your signing secret.
141
+
142
+ ```python
143
+ import os
144
+ import logging
145
+ from dataclasses import dataclass
146
+ from typing import Optional, Dict, Any
147
+ from flask import Flask, request, jsonify
148
+ from reevit import verify_webhook_signature
149
+
150
+ app = Flask(__name__)
151
+ logging.basicConfig(level=logging.INFO)
152
+ logger = logging.getLogger(__name__)
153
+
154
+ @dataclass
155
+ class PaymentData:
156
+ id: str
157
+ status: str
158
+ amount: int
159
+ currency: str
160
+ provider: str
161
+ customer_id: Optional[str] = None
162
+ metadata: Optional[Dict[str, str]] = None
163
+
164
+ @dataclass
165
+ class SubscriptionData:
166
+ id: str
167
+ customer_id: str
168
+ plan_id: str
169
+ status: str
170
+ amount: int
171
+ currency: str
172
+ interval: str
173
+ next_renewal_at: Optional[str] = None
174
+
175
+ @app.route('/webhooks/reevit', methods=['POST'])
176
+ def webhook():
177
+ payload = request.get_data() # raw bytes — do not re-serialize
178
+ signature = request.headers.get('X-Reevit-Signature', '')
179
+ secret = os.environ.get('REEVIT_WEBHOOK_SECRET', '')
180
+
181
+ # Verify signature (required in production)
182
+ if not verify_webhook_signature(payload, signature, secret):
183
+ logger.warning('[Webhook] Invalid signature')
184
+ return jsonify({'error': 'Invalid signature'}), 401
185
+
186
+ event = request.get_json()
187
+ event_type = event.get('type')
188
+ event_id = event.get('id')
189
+
190
+ logger.info(f'[Webhook] Received: {event_type} ({event_id})')
191
+
192
+ # Handle different event types
193
+ if event_type == 'reevit.webhook.test':
194
+ logger.info(f'[Webhook] Test received: {event.get("message")}')
195
+
196
+ # Payment events
197
+ elif event_type == 'payment.succeeded':
198
+ data = PaymentData(**event.get('data', {}))
199
+ handle_payment_succeeded(data)
200
+
201
+ elif event_type == 'payment.failed':
202
+ data = PaymentData(**event.get('data', {}))
203
+ handle_payment_failed(data)
204
+
205
+ elif event_type == 'payment.refunded':
206
+ data = PaymentData(**event.get('data', {}))
207
+ handle_payment_refunded(data)
208
+
209
+ elif event_type == 'payment.pending':
210
+ data = PaymentData(**event.get('data', {}))
211
+ logger.info(f'[Webhook] Payment pending: {data.id}')
212
+
213
+ # Subscription events
214
+ elif event_type == 'subscription.created':
215
+ data = SubscriptionData(**event.get('data', {}))
216
+ handle_subscription_created(data)
217
+
218
+ elif event_type == 'subscription.renewed':
219
+ data = SubscriptionData(**event.get('data', {}))
220
+ handle_subscription_renewed(data)
221
+
222
+ elif event_type == 'subscription.canceled':
223
+ data = SubscriptionData(**event.get('data', {}))
224
+ handle_subscription_canceled(data)
225
+
226
+ else:
227
+ logger.info(f'[Webhook] Unhandled event: {event_type}')
228
+
229
+ return jsonify({'received': True})
230
+
231
+ # Payment handlers
232
+ def handle_payment_succeeded(data: PaymentData):
233
+ order_id = data.metadata.get('order_id') if data.metadata else None
234
+ logger.info(f'[Webhook] Payment succeeded: {data.id} for order {order_id}')
235
+
236
+ # TODO: Implement your business logic
237
+ # - Update order status to "paid"
238
+ # - Send confirmation email to customer
239
+ # - Trigger fulfillment process
240
+
241
+ def handle_payment_failed(data: PaymentData):
242
+ logger.info(f'[Webhook] Payment failed: {data.id}')
243
+
244
+ # TODO: Implement your business logic
245
+ # - Update order status to "payment_failed"
246
+ # - Send notification to customer
247
+ # - Allow retry
248
+
249
+ def handle_payment_refunded(data: PaymentData):
250
+ order_id = data.metadata.get('order_id') if data.metadata else None
251
+ logger.info(f'[Webhook] Payment refunded: {data.id} for order {order_id}')
252
+
253
+ # TODO: Implement your business logic
254
+ # - Update order status to "refunded"
255
+ # - Restore inventory if applicable
256
+
257
+ # Subscription handlers
258
+ def handle_subscription_created(data: SubscriptionData):
259
+ logger.info(f'[Webhook] Subscription created: {data.id} for customer {data.customer_id}')
260
+
261
+ # TODO: Implement your business logic
262
+ # - Grant access to subscription features
263
+ # - Send welcome email
264
+
265
+ def handle_subscription_renewed(data: SubscriptionData):
266
+ logger.info(f'[Webhook] Subscription renewed: {data.id}')
267
+
268
+ # TODO: Implement your business logic
269
+ # - Extend access period
270
+ # - Send renewal confirmation
271
+
272
+ def handle_subscription_canceled(data: SubscriptionData):
273
+ logger.info(f'[Webhook] Subscription canceled: {data.id}')
274
+
275
+ # TODO: Implement your business logic
276
+ # - Revoke access at end of billing period
277
+ # - Send cancellation confirmation
278
+
279
+ if __name__ == '__main__':
280
+ app.run(port=8080)
281
+ ```
282
+
283
+ ### Django Webhook Handler
284
+
285
+ ```python
286
+ # views.py
287
+ import json
288
+ import os
289
+ import logging
290
+ from django.http import JsonResponse
291
+ from django.views.decorators.csrf import csrf_exempt
292
+ from django.views.decorators.http import require_POST
293
+ from reevit import verify_webhook_signature
294
+
295
+ logger = logging.getLogger(__name__)
296
+
297
+ @csrf_exempt
298
+ @require_POST
299
+ def reevit_webhook(request):
300
+ payload = request.body # raw bytes — do not re-serialize
301
+ signature = request.headers.get('X-Reevit-Signature', '')
302
+ secret = os.environ.get('REEVIT_WEBHOOK_SECRET', '')
303
+
304
+ if not verify_webhook_signature(payload, signature, secret):
305
+ return JsonResponse({'error': 'Invalid signature'}, status=401)
306
+
307
+ event = json.loads(payload)
308
+ event_type = event.get('type')
309
+
310
+ logger.info(f'[Webhook] Received: {event_type}')
311
+
312
+ if event_type == 'payment.succeeded':
313
+ data = event.get('data', {})
314
+ order_id = data.get('metadata', {}).get('order_id')
315
+ # Fulfill order, send confirmation email
316
+ logger.info(f'Payment succeeded for order {order_id}')
317
+
318
+ elif event_type == 'payment.failed':
319
+ # Notify customer, allow retry
320
+ pass
321
+
322
+ elif event_type == 'subscription.renewed':
323
+ # Extend access
324
+ pass
325
+
326
+ elif event_type == 'subscription.canceled':
327
+ # Revoke access
328
+ pass
329
+
330
+ return JsonResponse({'received': True})
331
+ ```
332
+
333
+ ### FastAPI Webhook Handler
334
+
335
+ ```python
336
+ from fastapi import FastAPI, Request, HTTPException
337
+ from pydantic import BaseModel
338
+ from typing import Optional, Dict
339
+ import os
340
+ import logging
341
+ from reevit import verify_webhook_signature
342
+
343
+ app = FastAPI()
344
+ logger = logging.getLogger(__name__)
345
+
346
+ class PaymentData(BaseModel):
347
+ id: str
348
+ status: str
349
+ amount: int
350
+ currency: str
351
+ provider: str
352
+ customer_id: Optional[str] = None
353
+ metadata: Optional[Dict[str, str]] = None
354
+
355
+ class SubscriptionData(BaseModel):
356
+ id: str
357
+ customer_id: str
358
+ plan_id: str
359
+ status: str
360
+ amount: int
361
+ currency: str
362
+ interval: str
363
+ next_renewal_at: Optional[str] = None
364
+
365
+ @app.post('/webhooks/reevit')
366
+ async def webhook(request: Request):
367
+ payload = await request.body() # raw bytes — do not re-serialize
368
+ signature = request.headers.get('X-Reevit-Signature', '')
369
+ secret = os.environ.get('REEVIT_WEBHOOK_SECRET', '')
370
+
371
+ if not verify_webhook_signature(payload, signature, secret):
372
+ raise HTTPException(status_code=401, detail='Invalid signature')
373
+
374
+ event = await request.json()
375
+ event_type = event.get('type')
376
+
377
+ logger.info(f'[Webhook] Received: {event_type}')
378
+
379
+ # Payment events
380
+ if event_type == 'payment.succeeded':
381
+ data = PaymentData(**event.get('data', {}))
382
+ order_id = data.metadata.get('order_id') if data.metadata else None
383
+ logger.info(f'Payment succeeded: {data.id} for order {order_id}')
384
+ # Fulfill order, send confirmation email
385
+
386
+ elif event_type == 'payment.failed':
387
+ # Notify customer, allow retry
388
+ pass
389
+
390
+ # Subscription events
391
+ elif event_type == 'subscription.renewed':
392
+ data = SubscriptionData(**event.get('data', {}))
393
+ logger.info(f'Subscription renewed: {data.id}')
394
+ # Extend access
395
+
396
+ elif event_type == 'subscription.canceled':
397
+ data = SubscriptionData(**event.get('data', {}))
398
+ logger.info(f'Subscription canceled: {data.id}')
399
+ # Revoke access
400
+
401
+ return {'received': True}
402
+ ```
403
+
404
+ ## Supported PSPs
405
+
406
+ | Provider | Countries | Payment Methods |
407
+ |----------|-----------|-----------------|
408
+ | Paystack | NG, GH, ZA, KE | Card, Mobile Money, Bank Transfer |
409
+ | Flutterwave | NG, GH, KE, ZA + | Card, Mobile Money, Bank Transfer |
410
+ | Hubtel | GH | Mobile Money |
411
+ | Stripe | Global (50+) | Card, Apple Pay, Google Pay |
412
+ | Monnify | NG | Card, Bank Transfer, USSD |
413
+ | M-Pesa | KE, TZ | Mobile Money (STK Push) |
414
+
415
+ ---
416
+
417
+ ## Release Notes
418
+
419
+ ### v0.9.1
420
+
421
+ - Added `verify_webhook_signature` / `sign_webhook_payload` helpers (constant-time HMAC-SHA256 verification of the `X-Reevit-Signature` header)
422
+ - Version is now sourced from `reevit._version` and sent as the `X-Reevit-Client-Version` header
423
+
424
+ ### v0.9.0
425
+
426
+ - Added server-created checkout sessions
427
+ - Version alignment across all Reevit SDKs
428
+ - Updated documentation and webhook examples
429
+ - Added support for Apple Pay and Google Pay
430
+ - Updated supported PSPs and payment methods documentation
431
+
432
+ ---
433
+
434
+ ## Environment Variables
435
+
436
+ ```bash
437
+ export REEVIT_API_KEY=pfk_live_xxx
438
+ export REEVIT_ORG_ID=org_xxx
439
+ export REEVIT_WEBHOOK_SECRET=whsec_xxx # Get from Dashboard > Developers > Webhooks
440
+ ```
441
+
442
+ ---
443
+
444
+ ## Support
445
+
446
+ - **Documentation**: [https://docs.reevit.io](https://docs.reevit.io)
447
+ - **GitHub Issues**: [https://github.com/Reevit-Platform/backend/issues](https://github.com/Reevit-Platform/backend/issues)
448
+ - **Email**: support@reevit.io
449
+
450
+ ## License
451
+
452
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,20 @@
1
+ reevit/__init__.py,sha256=prpmjhIWFfsweJo7XyFwjkOzfCw4o9zEWPKp2nu7Jwc,276
2
+ reevit/_version.py,sha256=VfQ_lu-jIwCa8kx_s31JSCAL4SGFr08ExXCvvbz7Ejs,312
3
+ reevit/client.py,sha256=Ygzd4KBI6pRSVfvQo7__p88JV2_Hk6O1U386UTR_HwY,3617
4
+ reevit/webhooks.py,sha256=q8uID1jmpqYGOiYQEt16dxCvbnpYrx4-MT-kHvbbQks,1999
5
+ reevit/services/__init__.py,sha256=p1afTZx1O_Sxog_xOa9yOi8s9zcXyBW5jyYoFujbhwc,155
6
+ reevit/services/checkout_sessions.py,sha256=X52wXJcyBHO7dRtdePhQF3sm4IOZBzChsGpYmtuKRrs,415
7
+ reevit/services/connections.py,sha256=x3ohIlL5hsi3Dy-MFm2wiHm_0y3UinnPSQcEPNHwL1A,2753
8
+ reevit/services/customers.py,sha256=1DRHki6gyLPhDK7MKl37c6mqsvM82x063h1n7uD3Tno,1970
9
+ reevit/services/fraud.py,sha256=WqoMt-d2pEviZkME27ROGbmRsSWqdrzCWTva7xCPZco,356
10
+ reevit/services/invoices.py,sha256=elJWEkGO59hqRJXNfRmQCO3ydazuRFZQz1V4RUXqHxg,1459
11
+ reevit/services/payment_links.py,sha256=o04oe2-OvKABN8KyNbJZ7FvQSLyQVDInboFPbH1oeVA,2011
12
+ reevit/services/payments.py,sha256=A1TjWdW00USwcWgfhoXlW5JbEpW2t-hcT_XQt15seIU,3000
13
+ reevit/services/routing_rules.py,sha256=vkKyyqatDuVnEDSgPLjFDXI8OiNH7gei2Iyc267ebto,1394
14
+ reevit/services/subscriptions.py,sha256=66gtMwBuGSPUWA_Hybg82kMx2mSUR5bIaJwju_AgKpM,1617
15
+ reevit/services/webhooks.py,sha256=Bgx2Z-Ll9fvq9AB24rODbqsa3nGHY2zl9qOFJeWECug,2117
16
+ reevit-0.9.1.dist-info/licenses/LICENSE,sha256=UDLWW01GC5P5RdsVrq3mq2xaIol__OoHOXNb_iSTh9M,1063
17
+ reevit-0.9.1.dist-info/METADATA,sha256=ar707ijBl-qxnXc_KiqR0ma_5_O-jcqKtCv2_AwkWiw,13904
18
+ reevit-0.9.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
19
+ reevit-0.9.1.dist-info/top_level.txt,sha256=5fiQTE6CazGDmrY12b129nI2f-W7GVrCMMOuU6AVb3U,7
20
+ reevit-0.9.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Reevit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ reevit