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.
- vellumcharter-0.1.0/LICENSE +21 -0
- vellumcharter-0.1.0/MANIFEST.in +4 -0
- vellumcharter-0.1.0/PKG-INFO +98 -0
- vellumcharter-0.1.0/README.md +83 -0
- vellumcharter-0.1.0/pyproject.toml +39 -0
- vellumcharter-0.1.0/setup.cfg +4 -0
- vellumcharter-0.1.0/vellumcharter/__init__.py +24 -0
- vellumcharter-0.1.0/vellumcharter/aio.py +215 -0
- vellumcharter-0.1.0/vellumcharter/client.py +346 -0
- vellumcharter-0.1.0/vellumcharter/errors.py +22 -0
- vellumcharter-0.1.0/vellumcharter/push.py +46 -0
- vellumcharter-0.1.0/vellumcharter.egg-info/PKG-INFO +98 -0
- vellumcharter-0.1.0/vellumcharter.egg-info/SOURCES.txt +13 -0
- vellumcharter-0.1.0/vellumcharter.egg-info/dependency_links.txt +1 -0
- vellumcharter-0.1.0/vellumcharter.egg-info/top_level.txt +1 -0
|
@@ -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,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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vellumcharter
|