fulkruma 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.
- fulkruma/__init__.py +18 -0
- fulkruma/client.py +237 -0
- fulkruma/errors.py +33 -0
- fulkruma/resources.py +351 -0
- fulkruma/webhooks.py +112 -0
- fulkruma-0.1.0.dist-info/METADATA +117 -0
- fulkruma-0.1.0.dist-info/RECORD +8 -0
- fulkruma-0.1.0.dist-info/WHEEL +4 -0
fulkruma/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Official Python SDK for Fulkruma.
|
|
2
|
+
|
|
3
|
+
Mirrors the Node SDK (`@forjio/fulkruma-node`) API surface — HMAC-signed
|
|
4
|
+
requests, 14 resource namespaces, optional `X-Fulkruma-On-Behalf-Of`
|
|
5
|
+
platform-admin scoping, plus webhook signature verification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import FulkrumaClient
|
|
9
|
+
from .errors import FulkrumaError
|
|
10
|
+
from .webhooks import verify_webhook
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"FulkrumaClient",
|
|
14
|
+
"FulkrumaError",
|
|
15
|
+
"verify_webhook",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
fulkruma/client.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""High-level Fulkruma HMAC client.
|
|
2
|
+
|
|
3
|
+
Mirrors `FulkrumaClient` from the Node SDK 1:1:
|
|
4
|
+
|
|
5
|
+
- ``Authorization: Fulkruma-HMAC-SHA256 keyId=…, scope=*, signature=…``
|
|
6
|
+
- ``X-Fulkruma-Timestamp: <unix>``
|
|
7
|
+
- ``X-Fulkruma-On-Behalf-Of: <accountId>`` for platform-admin keys
|
|
8
|
+
- ``Idempotency-Key`` on every mutation that needs replay safety
|
|
9
|
+
- Standard ``{ data, error, meta }`` envelope unwrapping
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .errors import FulkrumaError
|
|
25
|
+
from .resources import (
|
|
26
|
+
AddressesResources,
|
|
27
|
+
AdminResources,
|
|
28
|
+
ApiKeysResources,
|
|
29
|
+
AuditLogResources,
|
|
30
|
+
BillingResources,
|
|
31
|
+
DeliveriesResources,
|
|
32
|
+
IntegrationsResources,
|
|
33
|
+
LicensesResources,
|
|
34
|
+
ProductsResources,
|
|
35
|
+
ShipmentsResources,
|
|
36
|
+
ShippingResources,
|
|
37
|
+
StatsResources,
|
|
38
|
+
StockResources,
|
|
39
|
+
WarehousesResources,
|
|
40
|
+
WebhooksResources,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FulkrumaClient:
|
|
45
|
+
"""HMAC-signed client for the Fulkruma REST API.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
key_id:
|
|
50
|
+
HMAC access key id, e.g. ``AKIAFULK...``.
|
|
51
|
+
secret:
|
|
52
|
+
HMAC secret.
|
|
53
|
+
base_url:
|
|
54
|
+
Default ``https://fulkruma.com``. Trailing slashes are stripped.
|
|
55
|
+
on_behalf_of:
|
|
56
|
+
Optional merchant ``accountId`` — forwarded as
|
|
57
|
+
``X-Fulkruma-On-Behalf-Of``. Only allowed when ``key_id`` holds
|
|
58
|
+
the ``fulkruma:platform:admin`` scope.
|
|
59
|
+
timeout_ms:
|
|
60
|
+
Per-request timeout in milliseconds. Default 30 000.
|
|
61
|
+
http:
|
|
62
|
+
Inject a pre-configured ``httpx.Client`` (e.g. with a custom
|
|
63
|
+
transport for tests). Caller owns its lifecycle.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
key_id: str,
|
|
70
|
+
secret: str,
|
|
71
|
+
base_url: str = "https://fulkruma.com",
|
|
72
|
+
on_behalf_of: Optional[str] = None,
|
|
73
|
+
timeout_ms: int = 30_000,
|
|
74
|
+
http: Optional[httpx.Client] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
if not key_id or not secret:
|
|
77
|
+
raise ValueError("FulkrumaClient: key_id and secret are required")
|
|
78
|
+
self.key_id = key_id
|
|
79
|
+
self._secret = secret
|
|
80
|
+
self.base_url = base_url.rstrip("/")
|
|
81
|
+
self._default_obo = on_behalf_of
|
|
82
|
+
self.timeout_ms = timeout_ms
|
|
83
|
+
self._http = http or httpx.Client(timeout=timeout_ms / 1000.0)
|
|
84
|
+
self._owns_http = http is None
|
|
85
|
+
|
|
86
|
+
# Resource namespaces — match the Node SDK property names.
|
|
87
|
+
self.products = ProductsResources(self)
|
|
88
|
+
self.warehouses = WarehousesResources(self)
|
|
89
|
+
self.stock = StockResources(self)
|
|
90
|
+
self.addresses = AddressesResources(self)
|
|
91
|
+
self.shipments = ShipmentsResources(self)
|
|
92
|
+
self.shipping = ShippingResources(self)
|
|
93
|
+
self.licenses = LicensesResources(self)
|
|
94
|
+
self.deliveries = DeliveriesResources(self)
|
|
95
|
+
self.api_keys = ApiKeysResources(self)
|
|
96
|
+
self.audit_log = AuditLogResources(self)
|
|
97
|
+
self.billing = BillingResources(self)
|
|
98
|
+
self.integrations = IntegrationsResources(self)
|
|
99
|
+
self.stats = StatsResources(self)
|
|
100
|
+
self.webhooks = WebhooksResources(self)
|
|
101
|
+
self.admin = AdminResources(self)
|
|
102
|
+
|
|
103
|
+
# ─── Lifecycle ───────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
if self._owns_http:
|
|
107
|
+
self._http.close()
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "FulkrumaClient":
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *_: Any) -> None:
|
|
113
|
+
self.close()
|
|
114
|
+
|
|
115
|
+
def for_merchant(self, account_id: str) -> "FulkrumaClient":
|
|
116
|
+
"""Clone scoped to a specific merchant. For platform-admin keys."""
|
|
117
|
+
return FulkrumaClient(
|
|
118
|
+
key_id=self.key_id,
|
|
119
|
+
secret=self._secret,
|
|
120
|
+
base_url=self.base_url,
|
|
121
|
+
on_behalf_of=account_id,
|
|
122
|
+
timeout_ms=self.timeout_ms,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# ─── Internal helpers ────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _qs(params: Dict[str, Any]) -> str:
|
|
129
|
+
entries = [(k, v) for k, v in params.items() if v is not None]
|
|
130
|
+
if not entries:
|
|
131
|
+
return ""
|
|
132
|
+
# str(bool) → "True"/"False"; Node SDK emits the JS coercion ("true"/"false")
|
|
133
|
+
norm = []
|
|
134
|
+
for k, v in entries:
|
|
135
|
+
if isinstance(v, bool):
|
|
136
|
+
norm.append((k, "true" if v else "false"))
|
|
137
|
+
else:
|
|
138
|
+
norm.append((k, str(v)))
|
|
139
|
+
return "?" + urlencode(norm)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _gen_idem() -> str:
|
|
143
|
+
return f"idem_{uuid.uuid4()}"
|
|
144
|
+
|
|
145
|
+
def _sign(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
method: str,
|
|
149
|
+
path: str,
|
|
150
|
+
body: Optional[str],
|
|
151
|
+
idempotency_key: Optional[str],
|
|
152
|
+
) -> Dict[str, str]:
|
|
153
|
+
ts = str(int(time.time()))
|
|
154
|
+
body_hash = hashlib.sha256((body or "").encode("utf-8")).hexdigest()
|
|
155
|
+
idem = f"\n{idempotency_key}" if idempotency_key else ""
|
|
156
|
+
string_to_sign = f"{method.upper()}\n{path}\n{ts}\n{body_hash}{idem}"
|
|
157
|
+
signature = hmac.new(
|
|
158
|
+
self._secret.encode("utf-8"),
|
|
159
|
+
string_to_sign.encode("utf-8"),
|
|
160
|
+
hashlib.sha256,
|
|
161
|
+
).hexdigest()
|
|
162
|
+
return {"signature": signature, "timestamp": ts}
|
|
163
|
+
|
|
164
|
+
# ─── Low-level request ───────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def request(
|
|
167
|
+
self,
|
|
168
|
+
method: str,
|
|
169
|
+
path: str,
|
|
170
|
+
*,
|
|
171
|
+
body: Any = None,
|
|
172
|
+
idempotency_key: Optional[str] = None,
|
|
173
|
+
on_behalf_of: Optional[str] = None,
|
|
174
|
+
) -> Any:
|
|
175
|
+
body_json = json.dumps(body, separators=(",", ":")) if body is not None else None
|
|
176
|
+
signed = self._sign(
|
|
177
|
+
method=method, path=path, body=body_json, idempotency_key=idempotency_key,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
headers: Dict[str, str] = {
|
|
181
|
+
"Accept": "application/json",
|
|
182
|
+
"Authorization": (
|
|
183
|
+
f"Fulkruma-HMAC-SHA256 keyId={self.key_id}, scope=*, "
|
|
184
|
+
f"signature={signed['signature']}"
|
|
185
|
+
),
|
|
186
|
+
"X-Fulkruma-Timestamp": signed["timestamp"],
|
|
187
|
+
}
|
|
188
|
+
if body_json is not None:
|
|
189
|
+
headers["Content-Type"] = "application/json"
|
|
190
|
+
if idempotency_key:
|
|
191
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
192
|
+
effective_obo = on_behalf_of if on_behalf_of is not None else self._default_obo
|
|
193
|
+
if effective_obo:
|
|
194
|
+
headers["X-Fulkruma-On-Behalf-Of"] = effective_obo
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
res = self._http.request(
|
|
198
|
+
method.upper(),
|
|
199
|
+
f"{self.base_url}{path}",
|
|
200
|
+
headers=headers,
|
|
201
|
+
content=body_json,
|
|
202
|
+
)
|
|
203
|
+
except httpx.TimeoutException as exc:
|
|
204
|
+
raise FulkrumaError(
|
|
205
|
+
0, "timeout", f"Fulkruma request timed out after {self.timeout_ms}ms",
|
|
206
|
+
) from exc
|
|
207
|
+
except httpx.HTTPError as exc:
|
|
208
|
+
raise FulkrumaError(0, "network_error", str(exc)) from exc
|
|
209
|
+
|
|
210
|
+
text = res.text
|
|
211
|
+
try:
|
|
212
|
+
env = json.loads(text) if text else {}
|
|
213
|
+
except ValueError:
|
|
214
|
+
raise FulkrumaError(
|
|
215
|
+
res.status_code, "invalid_response", f"Non-JSON response: {text[:200]}",
|
|
216
|
+
) from None
|
|
217
|
+
|
|
218
|
+
request_id = None
|
|
219
|
+
if isinstance(env, dict):
|
|
220
|
+
meta = env.get("meta")
|
|
221
|
+
if isinstance(meta, dict):
|
|
222
|
+
request_id = meta.get("requestId")
|
|
223
|
+
err = env.get("error")
|
|
224
|
+
else:
|
|
225
|
+
err = None
|
|
226
|
+
|
|
227
|
+
if res.status_code >= 400 or err:
|
|
228
|
+
if not err:
|
|
229
|
+
err = {"code": "unknown", "message": f"HTTP {res.status_code}"}
|
|
230
|
+
raise FulkrumaError(
|
|
231
|
+
res.status_code,
|
|
232
|
+
err.get("code", "unknown"),
|
|
233
|
+
err.get("message", f"HTTP {res.status_code}"),
|
|
234
|
+
request_id,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return env.get("data") if isinstance(env, dict) else env
|
fulkruma/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Typed error class for the Fulkruma SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors `FulkrumaError` from the Node SDK: every non-2xx response and
|
|
4
|
+
every enveloped `{ error: { code, message } }` payload raises this with
|
|
5
|
+
the upstream code preserved.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FulkrumaError(Exception):
|
|
14
|
+
"""Raised on any non-success response from a Fulkruma endpoint."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
status: int,
|
|
19
|
+
code: str,
|
|
20
|
+
message: str,
|
|
21
|
+
request_id: Optional[str] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.code = code
|
|
26
|
+
self.message = message
|
|
27
|
+
self.request_id = request_id
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return (
|
|
31
|
+
f"FulkrumaError(status={self.status}, code={self.code!r}, "
|
|
32
|
+
f"message={self.message!r}, request_id={self.request_id!r})"
|
|
33
|
+
)
|
fulkruma/resources.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Resource namespaces for FulkrumaClient — Python parity with fulkruma-node.
|
|
2
|
+
|
|
3
|
+
Each builder takes the parent FulkrumaClient and exposes methods that map
|
|
4
|
+
1:1 to backend REST routes. Every method accepts a keyword-only
|
|
5
|
+
``on_behalf_of`` for per-call merchant override (no-op unless the key
|
|
6
|
+
holds the ``fulkruma:platform:admin`` scope).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import FulkrumaClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Namespace:
|
|
18
|
+
def __init__(self, client: "FulkrumaClient") -> None:
|
|
19
|
+
self._c = client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProductsResources(_Namespace):
|
|
23
|
+
def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
24
|
+
return self._c.request(
|
|
25
|
+
"POST", "/api/v1/products", body=body,
|
|
26
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def get(self, product_id: str, *, on_behalf_of: Optional[str] = None):
|
|
30
|
+
return self._c.request("GET", f"/api/v1/products/{product_id}", on_behalf_of=on_behalf_of)
|
|
31
|
+
|
|
32
|
+
def list(self, *, archived: Optional[bool] = None, on_behalf_of: Optional[str] = None):
|
|
33
|
+
return self._c.request(
|
|
34
|
+
"GET", "/api/v1/products" + self._c._qs({"archived": archived}),
|
|
35
|
+
on_behalf_of=on_behalf_of,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def update(self, product_id: str, patch: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
39
|
+
return self._c.request(
|
|
40
|
+
"PATCH", f"/api/v1/products/{product_id}", body=patch, on_behalf_of=on_behalf_of,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def archive(self, product_id: str, *, on_behalf_of: Optional[str] = None):
|
|
44
|
+
return self._c.request(
|
|
45
|
+
"DELETE", f"/api/v1/products/{product_id}", on_behalf_of=on_behalf_of,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def add_variant(self, product_id: str, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
49
|
+
return self._c.request(
|
|
50
|
+
"POST", f"/api/v1/products/{product_id}/variants",
|
|
51
|
+
body=body, on_behalf_of=on_behalf_of,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def update_variant(
|
|
55
|
+
self, product_id: str, variant_id: str, patch: Dict[str, Any],
|
|
56
|
+
*, on_behalf_of: Optional[str] = None,
|
|
57
|
+
):
|
|
58
|
+
return self._c.request(
|
|
59
|
+
"PATCH", f"/api/v1/products/{product_id}/variants/{variant_id}",
|
|
60
|
+
body=patch, on_behalf_of=on_behalf_of,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def archive_variant(self, product_id: str, variant_id: str, *, on_behalf_of: Optional[str] = None):
|
|
64
|
+
return self._c.request(
|
|
65
|
+
"DELETE", f"/api/v1/products/{product_id}/variants/{variant_id}",
|
|
66
|
+
on_behalf_of=on_behalf_of,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WarehousesResources(_Namespace):
|
|
71
|
+
def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
72
|
+
return self._c.request("POST", "/api/v1/warehouses", body=body, on_behalf_of=on_behalf_of)
|
|
73
|
+
|
|
74
|
+
def list(self, *, on_behalf_of: Optional[str] = None):
|
|
75
|
+
return self._c.request("GET", "/api/v1/warehouses", on_behalf_of=on_behalf_of)
|
|
76
|
+
|
|
77
|
+
def update(self, warehouse_id: str, patch: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
78
|
+
return self._c.request(
|
|
79
|
+
"PATCH", f"/api/v1/warehouses/{warehouse_id}", body=patch, on_behalf_of=on_behalf_of,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def archive(self, warehouse_id: str, *, on_behalf_of: Optional[str] = None):
|
|
83
|
+
return self._c.request(
|
|
84
|
+
"DELETE", f"/api/v1/warehouses/{warehouse_id}", on_behalf_of=on_behalf_of,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class StockResources(_Namespace):
|
|
89
|
+
def levels(self, *, variant_id: Optional[str] = None, on_behalf_of: Optional[str] = None):
|
|
90
|
+
return self._c.request(
|
|
91
|
+
"GET", "/api/v1/stock/levels" + self._c._qs({"variant_id": variant_id}),
|
|
92
|
+
on_behalf_of=on_behalf_of,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def movements(self, *, variant_id: Optional[str] = None, on_behalf_of: Optional[str] = None):
|
|
96
|
+
return self._c.request(
|
|
97
|
+
"GET", "/api/v1/stock/movements" + self._c._qs({"variant_id": variant_id}),
|
|
98
|
+
on_behalf_of=on_behalf_of,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def reservations(self, *, on_behalf_of: Optional[str] = None):
|
|
102
|
+
return self._c.request("GET", "/api/v1/stock/reservations", on_behalf_of=on_behalf_of)
|
|
103
|
+
|
|
104
|
+
def adjust(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
105
|
+
return self._c.request(
|
|
106
|
+
"POST", "/api/v1/stock/adjust", body=body,
|
|
107
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AddressesResources(_Namespace):
|
|
112
|
+
def list(self, *, customer_id: Optional[str] = None, on_behalf_of: Optional[str] = None):
|
|
113
|
+
return self._c.request(
|
|
114
|
+
"GET", "/api/v1/addresses" + self._c._qs({"customer_id": customer_id}),
|
|
115
|
+
on_behalf_of=on_behalf_of,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
119
|
+
return self._c.request("POST", "/api/v1/addresses", body=body, on_behalf_of=on_behalf_of)
|
|
120
|
+
|
|
121
|
+
def delete(self, address_id: str, *, on_behalf_of: Optional[str] = None):
|
|
122
|
+
return self._c.request(
|
|
123
|
+
"DELETE", f"/api/v1/addresses/{address_id}", on_behalf_of=on_behalf_of,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ShipmentsResources(_Namespace):
|
|
128
|
+
def list(self, *, status: Optional[str] = None, on_behalf_of: Optional[str] = None):
|
|
129
|
+
return self._c.request(
|
|
130
|
+
"GET", "/api/v1/shipments" + self._c._qs({"status": status}),
|
|
131
|
+
on_behalf_of=on_behalf_of,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def get(self, shipment_id: str, *, on_behalf_of: Optional[str] = None):
|
|
135
|
+
return self._c.request("GET", f"/api/v1/shipments/{shipment_id}", on_behalf_of=on_behalf_of)
|
|
136
|
+
|
|
137
|
+
def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
138
|
+
return self._c.request(
|
|
139
|
+
"POST", "/api/v1/shipments", body=body,
|
|
140
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ShippingResources(_Namespace):
|
|
145
|
+
def couriers(self, *, on_behalf_of: Optional[str] = None):
|
|
146
|
+
return self._c.request("GET", "/api/v1/shipping/couriers", on_behalf_of=on_behalf_of)
|
|
147
|
+
|
|
148
|
+
def origin(self, *, on_behalf_of: Optional[str] = None):
|
|
149
|
+
return self._c.request("GET", "/api/v1/shipping/origin", on_behalf_of=on_behalf_of)
|
|
150
|
+
|
|
151
|
+
def set_origin(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
152
|
+
return self._c.request(
|
|
153
|
+
"PATCH", "/api/v1/shipping/origin", body=body, on_behalf_of=on_behalf_of,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def rates(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
157
|
+
return self._c.request(
|
|
158
|
+
"POST", "/api/v1/shipping/rates", body=body, on_behalf_of=on_behalf_of,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LicensesResources(_Namespace):
|
|
163
|
+
def list(self, *, on_behalf_of: Optional[str] = None):
|
|
164
|
+
return self._c.request("GET", "/api/v1/licenses", on_behalf_of=on_behalf_of)
|
|
165
|
+
|
|
166
|
+
def issue(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
167
|
+
return self._c.request(
|
|
168
|
+
"POST", "/api/v1/licenses", body=body,
|
|
169
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def revoke(self, license_id: str, *, on_behalf_of: Optional[str] = None):
|
|
173
|
+
return self._c.request(
|
|
174
|
+
"POST", f"/api/v1/licenses/{license_id}/revoke",
|
|
175
|
+
body={}, on_behalf_of=on_behalf_of,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def activate(self, body: Dict[str, Any]):
|
|
179
|
+
"""Public unauthenticated endpoint — buyers' apps call this."""
|
|
180
|
+
return self._c.request("POST", "/api/v1/licenses/activate", body=body)
|
|
181
|
+
|
|
182
|
+
def deactivate(self, body: Dict[str, Any]):
|
|
183
|
+
"""Public unauthenticated — release a previously-activated instance."""
|
|
184
|
+
return self._c.request("POST", "/api/v1/licenses/deactivate", body=body)
|
|
185
|
+
|
|
186
|
+
def validate(self, *, key: str, product_id: Optional[str] = None):
|
|
187
|
+
"""Public unauthenticated — software pings this on launch."""
|
|
188
|
+
return self._c.request(
|
|
189
|
+
"GET", "/api/v1/licenses/validate" + self._c._qs({"key": key, "productId": product_id}),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class DeliveriesResources(_Namespace):
|
|
194
|
+
def list(self, *, on_behalf_of: Optional[str] = None):
|
|
195
|
+
return self._c.request("GET", "/api/v1/deliveries", on_behalf_of=on_behalf_of)
|
|
196
|
+
|
|
197
|
+
def get(self, delivery_id: str, *, on_behalf_of: Optional[str] = None):
|
|
198
|
+
return self._c.request(
|
|
199
|
+
"GET", f"/api/v1/deliveries/{delivery_id}", on_behalf_of=on_behalf_of,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def create(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
203
|
+
return self._c.request(
|
|
204
|
+
"POST", "/api/v1/deliveries", body=body,
|
|
205
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ApiKeysResources(_Namespace):
|
|
210
|
+
def list(self, *, on_behalf_of: Optional[str] = None):
|
|
211
|
+
return self._c.request("GET", "/api/v1/api-keys", on_behalf_of=on_behalf_of)
|
|
212
|
+
|
|
213
|
+
def create(self, body: Optional[Dict[str, Any]] = None, *, on_behalf_of: Optional[str] = None):
|
|
214
|
+
return self._c.request(
|
|
215
|
+
"POST", "/api/v1/api-keys", body=body or {},
|
|
216
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def revoke(self, key_id: str, *, on_behalf_of: Optional[str] = None):
|
|
220
|
+
return self._c.request(
|
|
221
|
+
"POST", f"/api/v1/api-keys/{key_id}/revoke",
|
|
222
|
+
body={}, on_behalf_of=on_behalf_of,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class AuditLogResources(_Namespace):
|
|
227
|
+
def list(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
limit: Optional[int] = None,
|
|
231
|
+
cursor: Optional[str] = None,
|
|
232
|
+
since: Optional[str] = None,
|
|
233
|
+
event_type: Optional[str] = None,
|
|
234
|
+
on_behalf_of: Optional[str] = None,
|
|
235
|
+
):
|
|
236
|
+
return self._c.request(
|
|
237
|
+
"GET",
|
|
238
|
+
"/api/v1/audit-log" + self._c._qs({
|
|
239
|
+
"limit": limit,
|
|
240
|
+
"cursor": cursor,
|
|
241
|
+
"since": since,
|
|
242
|
+
"eventType": event_type,
|
|
243
|
+
}),
|
|
244
|
+
on_behalf_of=on_behalf_of,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class BillingResources(_Namespace):
|
|
249
|
+
def plans(self, *, on_behalf_of: Optional[str] = None):
|
|
250
|
+
return self._c.request("GET", "/api/v1/billing/plans", on_behalf_of=on_behalf_of)
|
|
251
|
+
|
|
252
|
+
def current_plan(self, *, on_behalf_of: Optional[str] = None):
|
|
253
|
+
return self._c.request("GET", "/api/v1/billing/plan", on_behalf_of=on_behalf_of)
|
|
254
|
+
|
|
255
|
+
def subscription(self, *, on_behalf_of: Optional[str] = None):
|
|
256
|
+
return self._c.request("GET", "/api/v1/billing/subscription", on_behalf_of=on_behalf_of)
|
|
257
|
+
|
|
258
|
+
def usage(self, *, on_behalf_of: Optional[str] = None):
|
|
259
|
+
return self._c.request("GET", "/api/v1/billing/usage", on_behalf_of=on_behalf_of)
|
|
260
|
+
|
|
261
|
+
def invoices(
|
|
262
|
+
self, *, limit: Optional[int] = None, cursor: Optional[str] = None,
|
|
263
|
+
on_behalf_of: Optional[str] = None,
|
|
264
|
+
):
|
|
265
|
+
return self._c.request(
|
|
266
|
+
"GET",
|
|
267
|
+
"/api/v1/billing/invoices" + self._c._qs({"limit": limit, "cursor": cursor}),
|
|
268
|
+
on_behalf_of=on_behalf_of,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def checkout(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
272
|
+
return self._c.request(
|
|
273
|
+
"POST", "/api/v1/billing/checkout", body=body, on_behalf_of=on_behalf_of,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def cancel(self, *, on_behalf_of: Optional[str] = None):
|
|
277
|
+
return self._c.request(
|
|
278
|
+
"POST", "/api/v1/billing/cancel", body={}, on_behalf_of=on_behalf_of,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class IntegrationsResources(_Namespace):
|
|
283
|
+
def status(self, *, on_behalf_of: Optional[str] = None):
|
|
284
|
+
return self._c.request("GET", "/api/v1/integrations/status", on_behalf_of=on_behalf_of)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class StatsResources(_Namespace):
|
|
288
|
+
def overview(self, *, on_behalf_of: Optional[str] = None):
|
|
289
|
+
return self._c.request("GET", "/api/v1/stats/overview", on_behalf_of=on_behalf_of)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class WebhooksResources(_Namespace):
|
|
293
|
+
def list_endpoints(self, *, on_behalf_of: Optional[str] = None):
|
|
294
|
+
return self._c.request(
|
|
295
|
+
"GET", "/api/v1/webhooks/endpoints", on_behalf_of=on_behalf_of,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def create_endpoint(self, body: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
299
|
+
return self._c.request(
|
|
300
|
+
"POST", "/api/v1/webhooks/endpoints", body=body,
|
|
301
|
+
idempotency_key=self._c._gen_idem(), on_behalf_of=on_behalf_of,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def update_endpoint(self, endpoint_id: str, patch: Dict[str, Any], *, on_behalf_of: Optional[str] = None):
|
|
305
|
+
return self._c.request(
|
|
306
|
+
"PATCH", f"/api/v1/webhooks/endpoints/{endpoint_id}",
|
|
307
|
+
body=patch, on_behalf_of=on_behalf_of,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def delete_endpoint(self, endpoint_id: str, *, on_behalf_of: Optional[str] = None):
|
|
311
|
+
return self._c.request(
|
|
312
|
+
"DELETE", f"/api/v1/webhooks/endpoints/{endpoint_id}",
|
|
313
|
+
on_behalf_of=on_behalf_of,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def list_events(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
limit: Optional[int] = None,
|
|
320
|
+
cursor: Optional[str] = None,
|
|
321
|
+
type: Optional[str] = None,
|
|
322
|
+
on_behalf_of: Optional[str] = None,
|
|
323
|
+
):
|
|
324
|
+
return self._c.request(
|
|
325
|
+
"GET",
|
|
326
|
+
"/api/v1/webhooks/events"
|
|
327
|
+
+ self._c._qs({"limit": limit, "cursor": cursor, "type": type}),
|
|
328
|
+
on_behalf_of=on_behalf_of,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class AdminResources(_Namespace):
|
|
333
|
+
def provision_workspace(self, body: Dict[str, Any]):
|
|
334
|
+
"""Idempotent — refuses to re-route a workspace already provisioned
|
|
335
|
+
by a different partner. Mirrors the Node SDK key `ws_<acc>_<partner>`."""
|
|
336
|
+
account_id = body.get("accountId", "unknown")
|
|
337
|
+
partner = body.get("partner", "unknown")
|
|
338
|
+
return self._c.request(
|
|
339
|
+
"POST", "/api/v1/admin/workspaces", body=body,
|
|
340
|
+
idempotency_key=f"ws_{account_id}_{partner}",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def get_workspace(self, account_id: str):
|
|
344
|
+
return self._c.request("GET", f"/api/v1/admin/workspaces/{account_id}")
|
|
345
|
+
|
|
346
|
+
def partner_usage(self, *, partner: str, from_: str, to: str):
|
|
347
|
+
return self._c.request(
|
|
348
|
+
"GET",
|
|
349
|
+
"/api/v1/admin/partner/usage"
|
|
350
|
+
+ self._c._qs({"partner": partner, "from": from_, "to": to}),
|
|
351
|
+
)
|
fulkruma/webhooks.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""HMAC-SHA256 webhook signature verifier for Fulkruma.
|
|
2
|
+
|
|
3
|
+
Fulkruma signs every webhook delivery with::
|
|
4
|
+
|
|
5
|
+
Fulkruma-Signature: t=<unix_seconds>,v1=<hex>
|
|
6
|
+
|
|
7
|
+
where ``<hex> = HMAC-SHA256(secret, f"{t}.{rawBody}")``. The *raw* body
|
|
8
|
+
bytes are what got signed — re-serialising parsed JSON will produce
|
|
9
|
+
different whitespace and the signature will never match.
|
|
10
|
+
|
|
11
|
+
Flask example::
|
|
12
|
+
|
|
13
|
+
from flask import Flask, request, abort
|
|
14
|
+
from fulkruma import verify_webhook, FulkrumaError
|
|
15
|
+
|
|
16
|
+
app = Flask(__name__)
|
|
17
|
+
|
|
18
|
+
@app.post("/webhooks/fulkruma")
|
|
19
|
+
def fulkruma_hook():
|
|
20
|
+
raw = request.get_data()
|
|
21
|
+
sig = request.headers.get("Fulkruma-Signature")
|
|
22
|
+
try:
|
|
23
|
+
event = verify_webhook(raw_body=raw, signature=sig, secret=SECRET)
|
|
24
|
+
except FulkrumaError:
|
|
25
|
+
abort(400)
|
|
26
|
+
# event["type"], event["data"], ...
|
|
27
|
+
return "", 204
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import hmac
|
|
34
|
+
import json
|
|
35
|
+
import time
|
|
36
|
+
from typing import Any, Dict, Optional, Union
|
|
37
|
+
|
|
38
|
+
from .errors import FulkrumaError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def verify_webhook(
|
|
42
|
+
*,
|
|
43
|
+
raw_body: Union[bytes, str],
|
|
44
|
+
signature: Optional[str],
|
|
45
|
+
secret: str,
|
|
46
|
+
tolerance_sec: int = 300,
|
|
47
|
+
now: Optional[float] = None,
|
|
48
|
+
) -> Dict[str, Any]:
|
|
49
|
+
"""Verify and parse an inbound Fulkruma webhook.
|
|
50
|
+
|
|
51
|
+
Returns the decoded event envelope on success. Raises
|
|
52
|
+
:class:`FulkrumaError` on any failure (missing header, drift, bad
|
|
53
|
+
signature, malformed JSON).
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
raw_body:
|
|
58
|
+
The unparsed request body as received over the wire. ``str`` is
|
|
59
|
+
encoded as UTF-8 before hashing.
|
|
60
|
+
signature:
|
|
61
|
+
The value of the ``Fulkruma-Signature`` header.
|
|
62
|
+
secret:
|
|
63
|
+
The endpoint's shared signing secret.
|
|
64
|
+
tolerance_sec:
|
|
65
|
+
Maximum drift in seconds — defaults to 300 (5 min), matching the
|
|
66
|
+
Node SDK and Stripe/GitHub conventions.
|
|
67
|
+
now:
|
|
68
|
+
Inject a clock for tests; seconds since epoch.
|
|
69
|
+
"""
|
|
70
|
+
if not signature:
|
|
71
|
+
raise FulkrumaError(0, "missing_signature", "missing Fulkruma-Signature header")
|
|
72
|
+
|
|
73
|
+
parts: Dict[str, str] = {}
|
|
74
|
+
for segment in signature.split(","):
|
|
75
|
+
segment = segment.strip()
|
|
76
|
+
if not segment or "=" not in segment:
|
|
77
|
+
continue
|
|
78
|
+
k, _, v = segment.partition("=")
|
|
79
|
+
parts[k.strip()] = v.strip()
|
|
80
|
+
|
|
81
|
+
ts = parts.get("t")
|
|
82
|
+
v1 = parts.get("v1")
|
|
83
|
+
if not ts or not v1:
|
|
84
|
+
raise FulkrumaError(0, "malformed_signature", "malformed signature header")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
ts_num = int(ts)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise FulkrumaError(0, "malformed_signature", "non-numeric timestamp") from None
|
|
90
|
+
|
|
91
|
+
current = now if now is not None else time.time()
|
|
92
|
+
drift = abs(int(current) - ts_num)
|
|
93
|
+
if drift > tolerance_sec:
|
|
94
|
+
raise FulkrumaError(
|
|
95
|
+
0, "signature_expired", f"signature timestamp {drift}s out of tolerance",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
99
|
+
expected = hmac.new(
|
|
100
|
+
secret.encode("utf-8"),
|
|
101
|
+
f"{ts}.".encode("utf-8") + body_bytes,
|
|
102
|
+
hashlib.sha256,
|
|
103
|
+
).hexdigest()
|
|
104
|
+
|
|
105
|
+
if not hmac.compare_digest(expected, v1):
|
|
106
|
+
raise FulkrumaError(0, "bad_signature", "bad signature")
|
|
107
|
+
|
|
108
|
+
body_str = body_bytes.decode("utf-8")
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(body_str)
|
|
111
|
+
except ValueError:
|
|
112
|
+
raise FulkrumaError(0, "invalid_body", "webhook body is not valid JSON") from None
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fulkruma
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Fulkruma — stock + warehouses + shipping + licenses + deliveries. HMAC-signed requests with full parity to the Node SDK.
|
|
5
|
+
Project-URL: Homepage, https://fulkruma.com
|
|
6
|
+
Project-URL: Source, https://github.com/hachimi-cat/saas-fulkruma/tree/master/sdk/python
|
|
7
|
+
Project-URL: Issues, https://github.com/hachimi-cat/saas-fulkruma/issues
|
|
8
|
+
Author-email: Forjio <hello@fulkruma.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: biteship,forjio,fulkruma,licenses,shipping,stock,warehouses
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# fulkruma (Python)
|
|
25
|
+
|
|
26
|
+
Official Python SDK for [Fulkruma](https://fulkruma.com) — stock,
|
|
27
|
+
warehouses, shipping, licenses, deliveries. Mirrors the Node SDK
|
|
28
|
+
(`@forjio/fulkruma-node`) API surface 1:1.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install fulkruma
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from fulkruma import FulkrumaClient
|
|
40
|
+
|
|
41
|
+
client = FulkrumaClient(
|
|
42
|
+
key_id="AKIAFULK...",
|
|
43
|
+
secret="sk_...",
|
|
44
|
+
base_url="https://fulkruma.com", # default
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Create a warehouse
|
|
48
|
+
wh = client.warehouses.create({"name": "Jakarta DC", "city": "Jakarta"})
|
|
49
|
+
|
|
50
|
+
# Adjust stock
|
|
51
|
+
client.stock.adjust({
|
|
52
|
+
"variantId": "var_1",
|
|
53
|
+
"warehouseId": wh["warehouse"]["id"],
|
|
54
|
+
"delta": 50,
|
|
55
|
+
"reason": "initial_stock",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# List shipments
|
|
59
|
+
shipments = client.shipments.list(status="in_transit")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Platform-admin scope
|
|
63
|
+
|
|
64
|
+
Keys with the `fulkruma:platform:admin` scope can operate against any
|
|
65
|
+
merchant via the `X-Fulkruma-On-Behalf-Of` header. Either pass it on the
|
|
66
|
+
client:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
admin = FulkrumaClient(key_id="...", secret="...", on_behalf_of="acc_merchant")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
…or scope a single call:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
client.shipments.list(on_behalf_of="acc_merchant")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`for_merchant(account_id)` returns a cloned client locked to one merchant.
|
|
79
|
+
|
|
80
|
+
## Webhook verification
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from fulkruma import verify_webhook
|
|
84
|
+
|
|
85
|
+
# Flask
|
|
86
|
+
@app.post("/webhooks/fulkruma")
|
|
87
|
+
def hook():
|
|
88
|
+
raw = request.get_data() # bytes — verify the raw body, NOT parsed JSON
|
|
89
|
+
sig = request.headers.get("Fulkruma-Signature", "")
|
|
90
|
+
event = verify_webhook(raw_body=raw, signature=sig, secret=os.environ["WHSEC"])
|
|
91
|
+
# event["type"], event["data"], etc.
|
|
92
|
+
return "", 204
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Resource namespaces
|
|
96
|
+
|
|
97
|
+
| Namespace | Methods |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `products` | `create`, `get`, `list`, `update`, `archive`, `add_variant`, `update_variant`, `archive_variant` |
|
|
100
|
+
| `warehouses` | `create`, `list`, `update`, `archive` |
|
|
101
|
+
| `stock` | `levels`, `movements`, `reservations`, `adjust` |
|
|
102
|
+
| `addresses` | `list`, `create`, `delete` |
|
|
103
|
+
| `shipments` | `list`, `get`, `create` |
|
|
104
|
+
| `shipping` | `couriers`, `origin`, `set_origin`, `rates` |
|
|
105
|
+
| `licenses` | `list`, `issue`, `revoke`, `activate`, `deactivate`, `validate` |
|
|
106
|
+
| `deliveries` | `list`, `get`, `create` |
|
|
107
|
+
| `api_keys` | `list`, `create`, `revoke` |
|
|
108
|
+
| `audit_log` | `list` |
|
|
109
|
+
| `billing` | `plans`, `current_plan`, `subscription`, `usage`, `invoices`, `checkout`, `cancel` |
|
|
110
|
+
| `integrations` | `status` |
|
|
111
|
+
| `stats` | `overview` |
|
|
112
|
+
| `webhooks` | `list_endpoints`, `create_endpoint`, `update_endpoint`, `delete_endpoint`, `list_events` |
|
|
113
|
+
| `admin` | `provision_workspace`, `get_workspace`, `partner_usage` |
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
fulkruma/__init__.py,sha256=Guxw2mV-CqMCK8-60aviAH4me-mustCiK5wl7j7Ts_8,457
|
|
2
|
+
fulkruma/client.py,sha256=oFI22gnppq02yl37yVhAYhqW0-FIVNiZ9K7IOE7aO_A,8083
|
|
3
|
+
fulkruma/errors.py,sha256=WDRtb-Eswr-gu9TVFkDdJ0eWJETj0-SWxCfAVwBeXRo,901
|
|
4
|
+
fulkruma/resources.py,sha256=Za7BPszBoUTekkkiwchBnwf1dJbXpfTwXvES3jrppfA,13967
|
|
5
|
+
fulkruma/webhooks.py,sha256=B0LXIOAi-7BPKoksKbvZjQ7heCMmSLOnk4czhYwTQkU,3405
|
|
6
|
+
fulkruma-0.1.0.dist-info/METADATA,sha256=074i602FgnYPKoKIRFMtrk4kj2JFZ7WIyr05UoNbIO4,3601
|
|
7
|
+
fulkruma-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
fulkruma-0.1.0.dist-info/RECORD,,
|