vellumcharter 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Todd Esposito
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,4 @@
1
+ prune tests
2
+ prune .venv
3
+ prune .ruff_cache
4
+ global-exclude *.py[cod]
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: vellumcharter
3
+ Version: 0.1.0
4
+ Summary: Thin, dependency-free server-side client for the Vellum entitlements + billing API
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://vellumcharter.com
7
+ Project-URL: Repository, https://github.com/tdesposito/Vellum
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # vellumcharter (Python SDK)
17
+
18
+ A thin, **dependency-free** (stdlib `urllib` + `hmac`) server-side client for the
19
+ Vellum entitlements + billing API. The Python counterpart to `sdks/js/` (TypeScript);
20
+ built for Python consumers such as SAM2-SalesImport. **Server-side only** — it
21
+ holds a secret API key.
22
+
23
+ ```python
24
+ from vellumcharter import VellumClient, verify_push_signature
25
+
26
+ vellum = VellumClient(
27
+ api_key="vlm_sam.xxxxx", # from SSM, never shipped to a browser
28
+ tenant_id="sam",
29
+ # base_url defaults to https://api.vellumcharter.com; override for tests/staging.
30
+ )
31
+
32
+ # Gate a feature (pull-first; cached with a short TTL):
33
+ if vellum.can("unit_1", "imports"):
34
+ ...
35
+
36
+ ent = vellum.get_entitlements("unit_1")
37
+ ent.is_active() # True for active/trialing
38
+ ent.has("imports")
39
+ ent.config("trial_days")
40
+ ent.found # False if the customer has no record (no exception)
41
+ ```
42
+
43
+ ## Async
44
+
45
+ `AsyncVellumClient` is the awaitable counterpart — same constructor and methods,
46
+ each `await`-ed. It wraps the sync client on a worker thread (`asyncio.to_thread`),
47
+ so the SDK stays dependency-free. Use it from async frameworks (e.g. FastAPI):
48
+
49
+ ```python
50
+ from vellumcharter import AsyncVellumClient
51
+
52
+ vellum = AsyncVellumClient(api_key="vlm_sam.xxxxx", tenant_id="sam")
53
+
54
+ if await vellum.can("unit_1", "imports"):
55
+ ...
56
+
57
+ ent = await vellum.get_entitlements("unit_1")
58
+ vellum.invalidate("unit_1") # cache op is synchronous (no await)
59
+ ```
60
+
61
+ It is "async-compatible" (each call runs in a worker thread), not single-socket
62
+ async IO — the right trade-off for server-side entitlement checks while keeping
63
+ zero dependencies.
64
+
65
+ ## What it covers
66
+
67
+ - **Entitlements:** `get_entitlements`, `can`, opt-in TTL cache + `invalidate`.
68
+ A 404 returns an empty `EntitlementSet` (no exception), so gating never needs a
69
+ null check.
70
+ - **Checkout / subscriptions:** `create_checkout`, `create_setup_checkout`
71
+ (payment method, no charge), `create_subscription` (server-side trial),
72
+ `cancel_subscription`, `get_subscription`.
73
+ - **Provisioning:** `create_account` (adopt or create the Stripe customer),
74
+ `create_customer`, `set_account_status`, `set_customer_status`.
75
+ - **Billing facade:** `list_payment_methods`, `set_default_payment_method`,
76
+ `remove_payment_method`, `set_subscription_payment_method`, `list_invoices`,
77
+ `invoice_pdf_url` (returns Stripe's hosted PDF URL).
78
+ - **Push verification:** `verify_push_signature(raw_body, header, secret)` for the
79
+ consumer's `/webhooks/vellum` endpoint (mirrors `api/src/lib/notify.ts`).
80
+
81
+ `VellumAuthError` is raised on 401/403; `VellumApiError` (with `.status`/`.body`)
82
+ on other non-2xx; `VellumSignatureError` on a bad push signature.
83
+
84
+ ## Auth & errors
85
+
86
+ The API key is sent as `x-api-key` on every request. Keep it server-side.
87
+
88
+ ## Requirements & tooling
89
+
90
+ Python **3.11+** (3.9/3.10 are end-of-life). Managed with [uv](https://docs.astral.sh/uv/):
91
+
92
+ ```sh
93
+ uv sync # create the venv (pinned to 3.11 via .python-version)
94
+ uv run python -m unittest discover -s tests
95
+ ```
96
+
97
+ The SDK itself has no third-party dependencies — `uv` is just for the dev/test
98
+ environment and interpreter pinning.
@@ -0,0 +1,83 @@
1
+ # vellumcharter (Python SDK)
2
+
3
+ A thin, **dependency-free** (stdlib `urllib` + `hmac`) server-side client for the
4
+ Vellum entitlements + billing API. The Python counterpart to `sdks/js/` (TypeScript);
5
+ built for Python consumers such as SAM2-SalesImport. **Server-side only** — it
6
+ holds a secret API key.
7
+
8
+ ```python
9
+ from vellumcharter import VellumClient, verify_push_signature
10
+
11
+ vellum = VellumClient(
12
+ api_key="vlm_sam.xxxxx", # from SSM, never shipped to a browser
13
+ tenant_id="sam",
14
+ # base_url defaults to https://api.vellumcharter.com; override for tests/staging.
15
+ )
16
+
17
+ # Gate a feature (pull-first; cached with a short TTL):
18
+ if vellum.can("unit_1", "imports"):
19
+ ...
20
+
21
+ ent = vellum.get_entitlements("unit_1")
22
+ ent.is_active() # True for active/trialing
23
+ ent.has("imports")
24
+ ent.config("trial_days")
25
+ ent.found # False if the customer has no record (no exception)
26
+ ```
27
+
28
+ ## Async
29
+
30
+ `AsyncVellumClient` is the awaitable counterpart — same constructor and methods,
31
+ each `await`-ed. It wraps the sync client on a worker thread (`asyncio.to_thread`),
32
+ so the SDK stays dependency-free. Use it from async frameworks (e.g. FastAPI):
33
+
34
+ ```python
35
+ from vellumcharter import AsyncVellumClient
36
+
37
+ vellum = AsyncVellumClient(api_key="vlm_sam.xxxxx", tenant_id="sam")
38
+
39
+ if await vellum.can("unit_1", "imports"):
40
+ ...
41
+
42
+ ent = await vellum.get_entitlements("unit_1")
43
+ vellum.invalidate("unit_1") # cache op is synchronous (no await)
44
+ ```
45
+
46
+ It is "async-compatible" (each call runs in a worker thread), not single-socket
47
+ async IO — the right trade-off for server-side entitlement checks while keeping
48
+ zero dependencies.
49
+
50
+ ## What it covers
51
+
52
+ - **Entitlements:** `get_entitlements`, `can`, opt-in TTL cache + `invalidate`.
53
+ A 404 returns an empty `EntitlementSet` (no exception), so gating never needs a
54
+ null check.
55
+ - **Checkout / subscriptions:** `create_checkout`, `create_setup_checkout`
56
+ (payment method, no charge), `create_subscription` (server-side trial),
57
+ `cancel_subscription`, `get_subscription`.
58
+ - **Provisioning:** `create_account` (adopt or create the Stripe customer),
59
+ `create_customer`, `set_account_status`, `set_customer_status`.
60
+ - **Billing facade:** `list_payment_methods`, `set_default_payment_method`,
61
+ `remove_payment_method`, `set_subscription_payment_method`, `list_invoices`,
62
+ `invoice_pdf_url` (returns Stripe's hosted PDF URL).
63
+ - **Push verification:** `verify_push_signature(raw_body, header, secret)` for the
64
+ consumer's `/webhooks/vellum` endpoint (mirrors `api/src/lib/notify.ts`).
65
+
66
+ `VellumAuthError` is raised on 401/403; `VellumApiError` (with `.status`/`.body`)
67
+ on other non-2xx; `VellumSignatureError` on a bad push signature.
68
+
69
+ ## Auth & errors
70
+
71
+ The API key is sent as `x-api-key` on every request. Keep it server-side.
72
+
73
+ ## Requirements & tooling
74
+
75
+ Python **3.11+** (3.9/3.10 are end-of-life). Managed with [uv](https://docs.astral.sh/uv/):
76
+
77
+ ```sh
78
+ uv sync # create the venv (pinned to 3.11 via .python-version)
79
+ uv run python -m unittest discover -s tests
80
+ ```
81
+
82
+ The SDK itself has no third-party dependencies — `uv` is just for the dev/test
83
+ environment and interpreter pinning.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ # setuptools >= 77 supports the PEP 639 SPDX `license` string + license-files.
3
+ requires = ["setuptools>=77"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "vellumcharter"
8
+ version = "0.1.0"
9
+ description = "Thin, dependency-free server-side client for the Vellum entitlements + billing API"
10
+ readme = "README.md"
11
+ # 3.11 is the minimum supported runtime (3.9/3.10 are past end-of-life).
12
+ requires-python = ">=3.11"
13
+ license = "MIT"
14
+ # Intentionally dependency-free — stdlib urllib + hmac only (mirrors the TS SDK).
15
+ dependencies = []
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://vellumcharter.com"
24
+ Repository = "https://github.com/tdesposito/Vellum"
25
+
26
+ [tool.setuptools]
27
+ packages = ["vellumcharter"]
28
+
29
+ [tool.ruff]
30
+ target-version = "py311"
31
+ extend-exclude = [".venv"]
32
+
33
+ [tool.ruff.lint]
34
+ # Errors (E), pyflakes (F), import sorting (I). Modest on purpose — tighten later.
35
+ select = ["E", "F", "I"]
36
+ # Don't enforce line length on the existing (wider) code; adopt `ruff format` later
37
+ # if a hard width is wanted.
38
+ ignore = ["E501"]
39
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """vellumcharter — the Python SDK for the Vellum entitlements + billing API.
2
+ A thin, dependency-free server-side client plus push-webhook signature verification."""
3
+
4
+ from __future__ import annotations
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from .aio import AsyncVellumClient
9
+ from .client import EntitlementSet, HttpResponse, Transport, VellumClient
10
+ from .errors import VellumApiError, VellumAuthError, VellumError, VellumSignatureError
11
+ from .push import verify_push_signature
12
+
13
+ __all__ = [
14
+ "VellumClient",
15
+ "AsyncVellumClient",
16
+ "EntitlementSet",
17
+ "HttpResponse",
18
+ "Transport",
19
+ "VellumError",
20
+ "VellumAuthError",
21
+ "VellumApiError",
22
+ "VellumSignatureError",
23
+ "verify_push_signature",
24
+ ]
@@ -0,0 +1,215 @@
1
+ """Async client for the Vellum entitlements + billing API.
2
+
3
+ AsyncVellumClient is a thin wrapper over the synchronous VellumClient: every
4
+ network call is delegated to a worker thread via asyncio.to_thread, so it reuses
5
+ all of the sync client's logic (URL building, x-api-key, error mapping, TTL
6
+ cache) and keeps the SDK dependency-free. Server-side only — it holds a secret
7
+ API key.
8
+
9
+ This is "async-compatible" rather than single-socket async IO (each call hops to
10
+ a thread); for server-side entitlement checks that is the right trade-off. The
11
+ cache is shared with the inner sync client; under the GIL its dict ops are
12
+ atomic, so concurrent access is safe — the only race is two concurrent misses
13
+ for the same customer both fetching (a harmless duplicate request).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from .client import (
22
+ DEFAULT_BASE_URL,
23
+ DEFAULT_TTL_MS,
24
+ EntitlementSet,
25
+ Transport,
26
+ VellumClient,
27
+ )
28
+
29
+ __all__ = ["AsyncVellumClient"]
30
+
31
+
32
+ class AsyncVellumClient:
33
+ """Async wrapper over :class:`VellumClient`. Same constructor; each network
34
+ method is awaitable and delegates to a worker thread."""
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str,
39
+ tenant_id: str,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ cache_ttl_ms: int = DEFAULT_TTL_MS,
42
+ transport: Optional[Transport] = None,
43
+ ) -> None:
44
+ self._sync = VellumClient(
45
+ api_key=api_key,
46
+ tenant_id=tenant_id,
47
+ base_url=base_url,
48
+ cache_ttl_ms=cache_ttl_ms,
49
+ transport=transport,
50
+ )
51
+
52
+ # --- entitlements (hot path) -------------------------------------------
53
+
54
+ async def get_entitlements(self, customer_id: str) -> EntitlementSet:
55
+ return await asyncio.to_thread(self._sync.get_entitlements, customer_id)
56
+
57
+ async def can(self, customer_id: str, module_id: str) -> bool:
58
+ return await asyncio.to_thread(self._sync.can, customer_id, module_id)
59
+
60
+ def invalidate(self, customer_id: Optional[str] = None) -> None:
61
+ """Synchronous — only mutates the in-memory cache (no IO, no await)."""
62
+ self._sync.invalidate(customer_id)
63
+
64
+ # --- checkout / subscriptions ------------------------------------------
65
+
66
+ async def create_checkout(
67
+ self,
68
+ *,
69
+ account_id: str,
70
+ customer_id: str,
71
+ plan_id: str,
72
+ plan_version: int,
73
+ success_url: str,
74
+ cancel_url: str,
75
+ seats: int = 1,
76
+ ) -> Dict[str, Any]:
77
+ return await asyncio.to_thread(
78
+ lambda: self._sync.create_checkout(
79
+ account_id=account_id,
80
+ customer_id=customer_id,
81
+ plan_id=plan_id,
82
+ plan_version=plan_version,
83
+ success_url=success_url,
84
+ cancel_url=cancel_url,
85
+ seats=seats,
86
+ )
87
+ )
88
+
89
+ async def create_setup_checkout(
90
+ self, *, account_id: str, success_url: str, cancel_url: str
91
+ ) -> Dict[str, Any]:
92
+ return await asyncio.to_thread(
93
+ lambda: self._sync.create_setup_checkout(
94
+ account_id=account_id, success_url=success_url, cancel_url=cancel_url
95
+ )
96
+ )
97
+
98
+ async def create_subscription(
99
+ self,
100
+ *,
101
+ account_id: str,
102
+ customer_id: str,
103
+ plan_id: str,
104
+ plan_version: int,
105
+ seats: int = 1,
106
+ ) -> Dict[str, Any]:
107
+ return await asyncio.to_thread(
108
+ lambda: self._sync.create_subscription(
109
+ account_id=account_id,
110
+ customer_id=customer_id,
111
+ plan_id=plan_id,
112
+ plan_version=plan_version,
113
+ seats=seats,
114
+ )
115
+ )
116
+
117
+ async def cancel_subscription(
118
+ self, *, account_id: str, customer_id: str, at_period_end: bool = False
119
+ ) -> Dict[str, Any]:
120
+ return await asyncio.to_thread(
121
+ lambda: self._sync.cancel_subscription(
122
+ account_id=account_id, customer_id=customer_id, at_period_end=at_period_end
123
+ )
124
+ )
125
+
126
+ async def get_subscription(self, *, account_id: str, customer_id: str) -> Dict[str, Any]:
127
+ return await asyncio.to_thread(
128
+ lambda: self._sync.get_subscription(account_id=account_id, customer_id=customer_id)
129
+ )
130
+
131
+ # --- provisioning -------------------------------------------------------
132
+
133
+ async def create_account(
134
+ self,
135
+ *,
136
+ account_id: str,
137
+ name: Optional[str] = None,
138
+ email: Optional[str] = None,
139
+ stripe_customer_id: Optional[str] = None,
140
+ create_stripe_customer: bool = False,
141
+ ) -> Dict[str, Any]:
142
+ return await asyncio.to_thread(
143
+ lambda: self._sync.create_account(
144
+ account_id=account_id,
145
+ name=name,
146
+ email=email,
147
+ stripe_customer_id=stripe_customer_id,
148
+ create_stripe_customer=create_stripe_customer,
149
+ )
150
+ )
151
+
152
+ async def create_customer(
153
+ self, *, account_id: str, customer_id: str, email: Optional[str] = None
154
+ ) -> Dict[str, Any]:
155
+ return await asyncio.to_thread(
156
+ lambda: self._sync.create_customer(
157
+ account_id=account_id, customer_id=customer_id, email=email
158
+ )
159
+ )
160
+
161
+ async def set_account_status(self, *, account_id: str, status: str) -> Dict[str, Any]:
162
+ return await asyncio.to_thread(
163
+ lambda: self._sync.set_account_status(account_id=account_id, status=status)
164
+ )
165
+
166
+ async def set_customer_status(
167
+ self, *, account_id: str, customer_id: str, status: str
168
+ ) -> Dict[str, Any]:
169
+ return await asyncio.to_thread(
170
+ lambda: self._sync.set_customer_status(
171
+ account_id=account_id, customer_id=customer_id, status=status
172
+ )
173
+ )
174
+
175
+ # --- billing facade -----------------------------------------------------
176
+
177
+ async def list_payment_methods(self, *, account_id: str) -> List[Dict[str, Any]]:
178
+ return await asyncio.to_thread(
179
+ lambda: self._sync.list_payment_methods(account_id=account_id)
180
+ )
181
+
182
+ async def set_default_payment_method(
183
+ self, *, account_id: str, method_id: str
184
+ ) -> Dict[str, Any]:
185
+ return await asyncio.to_thread(
186
+ lambda: self._sync.set_default_payment_method(
187
+ account_id=account_id, method_id=method_id
188
+ )
189
+ )
190
+
191
+ async def remove_payment_method(self, *, account_id: str, method_id: str) -> Dict[str, Any]:
192
+ return await asyncio.to_thread(
193
+ lambda: self._sync.remove_payment_method(account_id=account_id, method_id=method_id)
194
+ )
195
+
196
+ async def set_subscription_payment_method(
197
+ self, *, account_id: str, customer_id: str, method_id: str
198
+ ) -> Dict[str, Any]:
199
+ return await asyncio.to_thread(
200
+ lambda: self._sync.set_subscription_payment_method(
201
+ account_id=account_id, customer_id=customer_id, method_id=method_id
202
+ )
203
+ )
204
+
205
+ async def list_invoices(
206
+ self, *, account_id: str, customer_id: Optional[str] = None
207
+ ) -> List[Dict[str, Any]]:
208
+ return await asyncio.to_thread(
209
+ lambda: self._sync.list_invoices(account_id=account_id, customer_id=customer_id)
210
+ )
211
+
212
+ async def invoice_pdf_url(self, *, account_id: str, invoice_id: str) -> str:
213
+ return await asyncio.to_thread(
214
+ lambda: self._sync.invoice_pdf_url(account_id=account_id, invoice_id=invoice_id)
215
+ )
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable, Dict, List, Optional
9
+ from urllib.parse import quote
10
+
11
+ from .errors import VellumApiError, VellumAuthError, VellumError
12
+
13
+ DEFAULT_TTL_MS = 30_000
14
+ DEFAULT_BASE_URL = "https://api.vellumcharter.com"
15
+ _REDIRECT_CODES = {301, 302, 303, 307, 308}
16
+
17
+
18
+ @dataclass
19
+ class HttpResponse:
20
+ status: int
21
+ headers: Dict[str, str]
22
+ body: str
23
+
24
+
25
+ # (method, url, headers, body) -> HttpResponse. Injectable for tests; the default
26
+ # uses urllib and does NOT follow redirects (so invoice_pdf can read Location).
27
+ Transport = Callable[[str, str, Dict[str, str], Optional[str]], HttpResponse]
28
+
29
+
30
+ @dataclass
31
+ class EntitlementSet:
32
+ """Ergonomic view over a customer's resolved entitlements. A customer with no
33
+ record (404) yields an empty, no-access set, so gating never needs a null
34
+ check: ``set.has('imports')`` is just False."""
35
+
36
+ data: Dict[str, Any] = field(default_factory=dict)
37
+ found: bool = False
38
+
39
+ @property
40
+ def status(self) -> Optional[str]:
41
+ return self.data.get("status")
42
+
43
+ def is_active(self) -> bool:
44
+ return self.status in ("active", "trialing")
45
+
46
+ def has(self, module_id: str) -> bool:
47
+ return module_id in self.data.get("modules", [])
48
+
49
+ def config(self, key: str, default: Any = None) -> Any:
50
+ return self.data.get("config", {}).get(key, default)
51
+
52
+
53
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
54
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401, ANN001
55
+ return None # surface the 3xx instead of following it
56
+
57
+
58
+ _opener = urllib.request.build_opener(_NoRedirect)
59
+
60
+
61
+ def _default_transport(
62
+ method: str, url: str, headers: Dict[str, str], body: Optional[str]
63
+ ) -> HttpResponse:
64
+ req = urllib.request.Request(
65
+ url, data=body.encode() if body is not None else None, method=method, headers=headers
66
+ )
67
+ try:
68
+ resp = _opener.open(req, timeout=15)
69
+ return HttpResponse(resp.status, _lower(resp.headers.items()), resp.read().decode())
70
+ except urllib.error.HTTPError as exc:
71
+ # Non-2xx (and our intentionally-unfollowed 3xx) land here.
72
+ raw = exc.read().decode() if exc.fp else ""
73
+ return HttpResponse(exc.code, _lower((exc.headers or {}).items()), raw)
74
+
75
+
76
+ def _lower(items) -> Dict[str, str]: # noqa: ANN001
77
+ return {k.lower(): v for k, v in items}
78
+
79
+
80
+ class VellumClient:
81
+ """Thin, dependency-free HTTP client for consuming apps (server-side — it
82
+ holds a secret API key). Wraps the entitlements read, checkout/subscribe,
83
+ billing, and provisioning endpoints, adds the x-api-key header, and caches
84
+ reads with a short TTL (the pull-first model)."""
85
+
86
+ def __init__(
87
+ self,
88
+ api_key: str,
89
+ tenant_id: str,
90
+ base_url: str = DEFAULT_BASE_URL,
91
+ cache_ttl_ms: int = DEFAULT_TTL_MS,
92
+ transport: Optional[Transport] = None,
93
+ ) -> None:
94
+ if not api_key or not tenant_id:
95
+ raise VellumError("api_key and tenant_id are required")
96
+ self._base = base_url.rstrip("/")
97
+ self._api_key = api_key
98
+ self._tenant = tenant_id
99
+ self._ttl_ms = cache_ttl_ms
100
+ self._transport = transport or _default_transport
101
+ self._cache: Dict[str, Any] = {}
102
+
103
+ # --- entitlements (hot path) -------------------------------------------
104
+
105
+ def get_entitlements(self, customer_id: str) -> EntitlementSet:
106
+ import time
107
+
108
+ cached = self._cache.get(customer_id)
109
+ if cached and cached["expires_at"] > time.time():
110
+ return cached["set"]
111
+
112
+ resp = self._request(
113
+ "GET", f"/tenants/{self._t}/customers/{quote(customer_id)}/entitlements"
114
+ )
115
+ if resp.status == 404:
116
+ result = EntitlementSet({}, False)
117
+ else:
118
+ result = EntitlementSet(self._parse(resp), True)
119
+
120
+ if self._ttl_ms > 0:
121
+ self._cache[customer_id] = {"set": result, "expires_at": time.time() + self._ttl_ms / 1000}
122
+ return result
123
+
124
+ def can(self, customer_id: str, module_id: str) -> bool:
125
+ return self.get_entitlements(customer_id).has(module_id)
126
+
127
+ def invalidate(self, customer_id: Optional[str] = None) -> None:
128
+ if customer_id is None:
129
+ self._cache.clear()
130
+ else:
131
+ self._cache.pop(customer_id, None)
132
+
133
+ # --- checkout / subscriptions ------------------------------------------
134
+
135
+ def create_checkout(
136
+ self,
137
+ *,
138
+ account_id: str,
139
+ customer_id: str,
140
+ plan_id: str,
141
+ plan_version: int,
142
+ success_url: str,
143
+ cancel_url: str,
144
+ seats: int = 1,
145
+ ) -> Dict[str, Any]:
146
+ return self._parse(
147
+ self._request(
148
+ "POST",
149
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/checkout",
150
+ body={
151
+ "customerId": customer_id,
152
+ "planId": plan_id,
153
+ "planVersion": plan_version,
154
+ "seats": seats,
155
+ "successUrl": success_url,
156
+ "cancelUrl": cancel_url,
157
+ },
158
+ )
159
+ )
160
+
161
+ def create_setup_checkout(
162
+ self, *, account_id: str, success_url: str, cancel_url: str
163
+ ) -> Dict[str, Any]:
164
+ return self._parse(
165
+ self._request(
166
+ "POST",
167
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/billing/setup-checkout",
168
+ body={"successUrl": success_url, "cancelUrl": cancel_url},
169
+ )
170
+ )
171
+
172
+ def create_subscription(
173
+ self,
174
+ *,
175
+ account_id: str,
176
+ customer_id: str,
177
+ plan_id: str,
178
+ plan_version: int,
179
+ seats: int = 1,
180
+ ) -> Dict[str, Any]:
181
+ return self._parse(
182
+ self._request(
183
+ "POST",
184
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/customers/{quote(customer_id)}/subscribe",
185
+ body={"planId": plan_id, "planVersion": plan_version, "seats": seats},
186
+ )
187
+ )
188
+
189
+ def cancel_subscription(
190
+ self, *, account_id: str, customer_id: str, at_period_end: bool = False
191
+ ) -> Dict[str, Any]:
192
+ return self._parse(
193
+ self._request(
194
+ "DELETE",
195
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/customers/{quote(customer_id)}/subscription",
196
+ query={"atPeriodEnd": "true"} if at_period_end else None,
197
+ )
198
+ )
199
+
200
+ def get_subscription(self, *, account_id: str, customer_id: str) -> Dict[str, Any]:
201
+ return self._parse(
202
+ self._request(
203
+ "GET",
204
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/customers/{quote(customer_id)}/subscription",
205
+ )
206
+ )
207
+
208
+ # --- provisioning -------------------------------------------------------
209
+
210
+ def create_account(
211
+ self,
212
+ *,
213
+ account_id: str,
214
+ name: Optional[str] = None,
215
+ email: Optional[str] = None,
216
+ stripe_customer_id: Optional[str] = None,
217
+ create_stripe_customer: bool = False,
218
+ ) -> Dict[str, Any]:
219
+ body: Dict[str, Any] = {"accountId": account_id}
220
+ if name is not None:
221
+ body["name"] = name
222
+ if email is not None:
223
+ body["email"] = email
224
+ if stripe_customer_id is not None:
225
+ body["stripeCustomerId"] = stripe_customer_id
226
+ if create_stripe_customer:
227
+ body["createStripeCustomer"] = True
228
+ return self._parse(self._request("POST", f"/tenants/{self._t}/accounts", body=body))
229
+
230
+ def create_customer(
231
+ self, *, account_id: str, customer_id: str, email: Optional[str] = None
232
+ ) -> Dict[str, Any]:
233
+ body: Dict[str, Any] = {"customerId": customer_id}
234
+ if email is not None:
235
+ body["email"] = email
236
+ return self._parse(
237
+ self._request(
238
+ "POST", f"/tenants/{self._t}/accounts/{quote(account_id)}/customers", body=body
239
+ )
240
+ )
241
+
242
+ def set_account_status(self, *, account_id: str, status: str) -> Dict[str, Any]:
243
+ return self._parse(
244
+ self._request(
245
+ "PATCH", f"/tenants/{self._t}/accounts/{quote(account_id)}", body={"status": status}
246
+ )
247
+ )
248
+
249
+ def set_customer_status(
250
+ self, *, account_id: str, customer_id: str, status: str
251
+ ) -> Dict[str, Any]:
252
+ return self._parse(
253
+ self._request(
254
+ "PATCH",
255
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/customers/{quote(customer_id)}",
256
+ body={"status": status},
257
+ )
258
+ )
259
+
260
+ # --- billing facade -----------------------------------------------------
261
+
262
+ def list_payment_methods(self, *, account_id: str) -> List[Dict[str, Any]]:
263
+ return self._parse(
264
+ self._request("GET", f"/tenants/{self._t}/accounts/{quote(account_id)}/payment-methods")
265
+ )["methods"]
266
+
267
+ def set_default_payment_method(self, *, account_id: str, method_id: str) -> Dict[str, Any]:
268
+ return self._parse(
269
+ self._request(
270
+ "PATCH",
271
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/payment-methods/{quote(method_id)}",
272
+ )
273
+ )
274
+
275
+ def remove_payment_method(self, *, account_id: str, method_id: str) -> Dict[str, Any]:
276
+ return self._parse(
277
+ self._request(
278
+ "DELETE",
279
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/payment-methods/{quote(method_id)}",
280
+ )
281
+ )
282
+
283
+ def set_subscription_payment_method(
284
+ self, *, account_id: str, customer_id: str, method_id: str
285
+ ) -> Dict[str, Any]:
286
+ return self._parse(
287
+ self._request(
288
+ "PATCH",
289
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/customers/{quote(customer_id)}/payment-method",
290
+ body={"methodId": method_id},
291
+ )
292
+ )
293
+
294
+ def list_invoices(
295
+ self, *, account_id: str, customer_id: Optional[str] = None
296
+ ) -> List[Dict[str, Any]]:
297
+ return self._parse(
298
+ self._request(
299
+ "GET",
300
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/invoices",
301
+ query={"customerId": customer_id} if customer_id else None,
302
+ )
303
+ )["invoices"]
304
+
305
+ def invoice_pdf_url(self, *, account_id: str, invoice_id: str) -> str:
306
+ resp = self._request(
307
+ "GET",
308
+ f"/tenants/{self._t}/accounts/{quote(account_id)}/invoices/{quote(invoice_id)}/pdf",
309
+ )
310
+ if resp.status in _REDIRECT_CODES:
311
+ location = resp.headers.get("location")
312
+ if location:
313
+ return location
314
+ self._parse(resp) # raises on a non-2xx that wasn't a redirect
315
+ raise VellumApiError("invoice has no PDF location", resp.status, resp.body)
316
+
317
+ # --- internals ----------------------------------------------------------
318
+
319
+ @property
320
+ def _t(self) -> str:
321
+ return quote(self._tenant)
322
+
323
+ def _request(
324
+ self,
325
+ method: str,
326
+ path: str,
327
+ *,
328
+ body: Optional[Dict[str, Any]] = None,
329
+ query: Optional[Dict[str, str]] = None,
330
+ ) -> HttpResponse:
331
+ url = self._base + path
332
+ if query:
333
+ url += "?" + urllib.parse.urlencode(query)
334
+ headers = {"x-api-key": self._api_key}
335
+ payload: Optional[str] = None
336
+ if body is not None:
337
+ headers["content-type"] = "application/json"
338
+ payload = json.dumps(body)
339
+ return self._transport(method, url, headers, payload)
340
+
341
+ def _parse(self, resp: HttpResponse) -> Any:
342
+ if resp.status in (401, 403):
343
+ raise VellumAuthError(f"Vellum auth failed ({resp.status})")
344
+ if not 200 <= resp.status < 300:
345
+ raise VellumApiError(f"Vellum API error ({resp.status})", resp.status, resp.body)
346
+ return json.loads(resp.body)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class VellumError(Exception):
5
+ """Base class for all Vellum SDK errors."""
6
+
7
+
8
+ class VellumAuthError(VellumError):
9
+ """The API key was rejected (HTTP 401/403)."""
10
+
11
+
12
+ class VellumApiError(VellumError):
13
+ """A non-2xx response that isn't an auth failure."""
14
+
15
+ def __init__(self, message: str, status: int, body: str) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.body = body
19
+
20
+
21
+ class VellumSignatureError(VellumError):
22
+ """A push webhook signature failed verification."""
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import Any, Dict, Union
8
+
9
+ from .errors import VellumSignatureError
10
+
11
+
12
+ def verify_push_signature(
13
+ payload: Union[str, bytes, bytearray],
14
+ header: str,
15
+ secret: str,
16
+ tolerance_s: int = 300,
17
+ ) -> Dict[str, Any]:
18
+ """Verify a Vellum push webhook and return the parsed event.
19
+
20
+ The signature header is ``t=<unix>,v1=<hex>`` where the hex is
21
+ ``HMAC-SHA256(secret, "<t>.<raw-body>")`` — mirroring lib/notify.ts on the
22
+ server. Pass the *raw* request body (bytes or str), not a re-serialized dict,
23
+ so the bytes match what was signed. Raises VellumSignatureError on any
24
+ mismatch, a malformed header, or a timestamp outside ``tolerance_s`` seconds
25
+ (set ``tolerance_s=0`` to skip the freshness check).
26
+ """
27
+ body = payload.decode() if isinstance(payload, (bytes, bytearray)) else payload
28
+
29
+ parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
30
+ timestamp, signature = parts.get("t"), parts.get("v1")
31
+ if not timestamp or not signature:
32
+ raise VellumSignatureError("malformed signature header")
33
+
34
+ try:
35
+ ts = int(timestamp)
36
+ except ValueError as exc:
37
+ raise VellumSignatureError("invalid signature timestamp") from exc
38
+
39
+ if tolerance_s and abs(time.time() - ts) > tolerance_s:
40
+ raise VellumSignatureError("signature timestamp outside tolerance")
41
+
42
+ expected = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
43
+ if not hmac.compare_digest(expected, signature):
44
+ raise VellumSignatureError("signature mismatch")
45
+
46
+ return json.loads(body)
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: vellumcharter
3
+ Version: 0.1.0
4
+ Summary: Thin, dependency-free server-side client for the Vellum entitlements + billing API
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://vellumcharter.com
7
+ Project-URL: Repository, https://github.com/tdesposito/Vellum
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # vellumcharter (Python SDK)
17
+
18
+ A thin, **dependency-free** (stdlib `urllib` + `hmac`) server-side client for the
19
+ Vellum entitlements + billing API. The Python counterpart to `sdks/js/` (TypeScript);
20
+ built for Python consumers such as SAM2-SalesImport. **Server-side only** — it
21
+ holds a secret API key.
22
+
23
+ ```python
24
+ from vellumcharter import VellumClient, verify_push_signature
25
+
26
+ vellum = VellumClient(
27
+ api_key="vlm_sam.xxxxx", # from SSM, never shipped to a browser
28
+ tenant_id="sam",
29
+ # base_url defaults to https://api.vellumcharter.com; override for tests/staging.
30
+ )
31
+
32
+ # Gate a feature (pull-first; cached with a short TTL):
33
+ if vellum.can("unit_1", "imports"):
34
+ ...
35
+
36
+ ent = vellum.get_entitlements("unit_1")
37
+ ent.is_active() # True for active/trialing
38
+ ent.has("imports")
39
+ ent.config("trial_days")
40
+ ent.found # False if the customer has no record (no exception)
41
+ ```
42
+
43
+ ## Async
44
+
45
+ `AsyncVellumClient` is the awaitable counterpart — same constructor and methods,
46
+ each `await`-ed. It wraps the sync client on a worker thread (`asyncio.to_thread`),
47
+ so the SDK stays dependency-free. Use it from async frameworks (e.g. FastAPI):
48
+
49
+ ```python
50
+ from vellumcharter import AsyncVellumClient
51
+
52
+ vellum = AsyncVellumClient(api_key="vlm_sam.xxxxx", tenant_id="sam")
53
+
54
+ if await vellum.can("unit_1", "imports"):
55
+ ...
56
+
57
+ ent = await vellum.get_entitlements("unit_1")
58
+ vellum.invalidate("unit_1") # cache op is synchronous (no await)
59
+ ```
60
+
61
+ It is "async-compatible" (each call runs in a worker thread), not single-socket
62
+ async IO — the right trade-off for server-side entitlement checks while keeping
63
+ zero dependencies.
64
+
65
+ ## What it covers
66
+
67
+ - **Entitlements:** `get_entitlements`, `can`, opt-in TTL cache + `invalidate`.
68
+ A 404 returns an empty `EntitlementSet` (no exception), so gating never needs a
69
+ null check.
70
+ - **Checkout / subscriptions:** `create_checkout`, `create_setup_checkout`
71
+ (payment method, no charge), `create_subscription` (server-side trial),
72
+ `cancel_subscription`, `get_subscription`.
73
+ - **Provisioning:** `create_account` (adopt or create the Stripe customer),
74
+ `create_customer`, `set_account_status`, `set_customer_status`.
75
+ - **Billing facade:** `list_payment_methods`, `set_default_payment_method`,
76
+ `remove_payment_method`, `set_subscription_payment_method`, `list_invoices`,
77
+ `invoice_pdf_url` (returns Stripe's hosted PDF URL).
78
+ - **Push verification:** `verify_push_signature(raw_body, header, secret)` for the
79
+ consumer's `/webhooks/vellum` endpoint (mirrors `api/src/lib/notify.ts`).
80
+
81
+ `VellumAuthError` is raised on 401/403; `VellumApiError` (with `.status`/`.body`)
82
+ on other non-2xx; `VellumSignatureError` on a bad push signature.
83
+
84
+ ## Auth & errors
85
+
86
+ The API key is sent as `x-api-key` on every request. Keep it server-side.
87
+
88
+ ## Requirements & tooling
89
+
90
+ Python **3.11+** (3.9/3.10 are end-of-life). Managed with [uv](https://docs.astral.sh/uv/):
91
+
92
+ ```sh
93
+ uv sync # create the venv (pinned to 3.11 via .python-version)
94
+ uv run python -m unittest discover -s tests
95
+ ```
96
+
97
+ The SDK itself has no third-party dependencies — `uv` is just for the dev/test
98
+ environment and interpreter pinning.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ vellumcharter/__init__.py
6
+ vellumcharter/aio.py
7
+ vellumcharter/client.py
8
+ vellumcharter/errors.py
9
+ vellumcharter/push.py
10
+ vellumcharter.egg-info/PKG-INFO
11
+ vellumcharter.egg-info/SOURCES.txt
12
+ vellumcharter.egg-info/dependency_links.txt
13
+ vellumcharter.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ vellumcharter