meterflow 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.
- meterflow/__init__.py +43 -0
- meterflow/_http.py +160 -0
- meterflow/_retry.py +38 -0
- meterflow/_version.py +3 -0
- meterflow/client.py +148 -0
- meterflow/errors.py +98 -0
- meterflow/py.typed +0 -0
- meterflow/resources/__init__.py +1 -0
- meterflow/resources/credits.py +77 -0
- meterflow/resources/entitlements.py +60 -0
- meterflow/resources/plans.py +49 -0
- meterflow/resources/subscriptions.py +89 -0
- meterflow/resources/usage.py +77 -0
- meterflow/types.py +214 -0
- meterflow/webhook.py +21 -0
- meterflow-0.1.0.dist-info/METADATA +560 -0
- meterflow-0.1.0.dist-info/RECORD +19 -0
- meterflow-0.1.0.dist-info/WHEEL +4 -0
- meterflow-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Plans — read-only through the SDK: pricing is managed by humans in the dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .._http import RequestSpec, encode_path_segment
|
|
8
|
+
from ..types import PlanResponse
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..client import AsyncMeterFlow, MeterFlow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_spec(project_id: str | None) -> RequestSpec:
|
|
15
|
+
return RequestSpec("GET", "plans", query={"project_id": project_id})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_spec(plan_id: str, project_id: str | None) -> RequestSpec:
|
|
19
|
+
return RequestSpec("GET", f"plans/{encode_path_segment(plan_id)}", query={"project_id": project_id})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlansResource:
|
|
23
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
def list(self, *, project_id: str | None = None) -> list[PlanResponse]:
|
|
27
|
+
"""The project's plans — render your pricing page from this so it can never drift from billing."""
|
|
28
|
+
result: list[PlanResponse] = self._client.request(list_spec(project_id))
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
def get(self, plan_id: str, *, project_id: str | None = None) -> PlanResponse:
|
|
32
|
+
"""One plan, including its per-meter limits and feature keys."""
|
|
33
|
+
result: PlanResponse = self._client.request(get_spec(plan_id, project_id))
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncPlansResource:
|
|
38
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
39
|
+
self._client = client
|
|
40
|
+
|
|
41
|
+
async def list(self, *, project_id: str | None = None) -> list[PlanResponse]:
|
|
42
|
+
"""The project's plans — render your pricing page from this so it can never drift from billing."""
|
|
43
|
+
result: list[PlanResponse] = await self._client.request(list_spec(project_id))
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
async def get(self, plan_id: str, *, project_id: str | None = None) -> PlanResponse:
|
|
47
|
+
"""One plan, including its per-meter limits and feature keys."""
|
|
48
|
+
result: PlanResponse = await self._client.request(get_spec(plan_id, project_id))
|
|
49
|
+
return result
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Subscriptions — one customer on one plan from a date; renews itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .._http import RequestSpec, encode_path_segment
|
|
8
|
+
from ..types import SubscriptionCreateRequest, SubscriptionResponse, SubscriptionUpdateRequest
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..client import AsyncMeterFlow, MeterFlow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_spec(body: SubscriptionCreateRequest, idempotency_key: str | None) -> RequestSpec:
|
|
15
|
+
return RequestSpec("POST", "subscriptions", body=body, idempotency_key=idempotency_key)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def list_spec(customer_id: str | None) -> RequestSpec:
|
|
19
|
+
return RequestSpec("GET", "subscriptions", query={"customer_id": customer_id})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_spec(subscription_id: str) -> RequestSpec:
|
|
23
|
+
return RequestSpec("GET", f"subscriptions/{encode_path_segment(subscription_id)}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def update_spec(subscription_id: str, body: SubscriptionUpdateRequest, idempotency_key: str | None) -> RequestSpec:
|
|
27
|
+
return RequestSpec("PATCH", f"subscriptions/{encode_path_segment(subscription_id)}", body=body, idempotency_key=idempotency_key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def delete_spec(subscription_id: str) -> RequestSpec:
|
|
31
|
+
return RequestSpec("DELETE", f"subscriptions/{encode_path_segment(subscription_id)}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SubscriptionsResource:
|
|
35
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
36
|
+
self._client = client
|
|
37
|
+
|
|
38
|
+
def create(self, body: SubscriptionCreateRequest, *, idempotency_key: str | None = None) -> SubscriptionResponse:
|
|
39
|
+
"""Put a customer on a plan. ``status`` is ``trialing`` if the plan has trial days, else ``active``."""
|
|
40
|
+
result: SubscriptionResponse = self._client.request(create_spec(body, idempotency_key))
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def list(self, *, customer_id: str | None = None) -> list[SubscriptionResponse]:
|
|
44
|
+
"""The project's subscriptions in this key's environment, optionally one customer's."""
|
|
45
|
+
result: list[SubscriptionResponse] = self._client.request(list_spec(customer_id))
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def get(self, subscription_id: str) -> SubscriptionResponse:
|
|
49
|
+
result: SubscriptionResponse = self._client.request(get_spec(subscription_id))
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
def update(self, subscription_id: str, body: SubscriptionUpdateRequest, *, idempotency_key: str | None = None) -> SubscriptionResponse:
|
|
53
|
+
"""Change status or metadata. Cancel with ``{"status": "canceled"}`` — the record stays for history."""
|
|
54
|
+
result: SubscriptionResponse = self._client.request(update_spec(subscription_id, body, idempotency_key))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def delete(self, subscription_id: str) -> None:
|
|
58
|
+
"""Remove the record entirely — for test data; prefer cancelling in production."""
|
|
59
|
+
self._client.request(delete_spec(subscription_id))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AsyncSubscriptionsResource:
|
|
63
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
64
|
+
self._client = client
|
|
65
|
+
|
|
66
|
+
async def create(self, body: SubscriptionCreateRequest, *, idempotency_key: str | None = None) -> SubscriptionResponse:
|
|
67
|
+
"""Put a customer on a plan. ``status`` is ``trialing`` if the plan has trial days, else ``active``."""
|
|
68
|
+
result: SubscriptionResponse = await self._client.request(create_spec(body, idempotency_key))
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
async def list(self, *, customer_id: str | None = None) -> list[SubscriptionResponse]:
|
|
72
|
+
"""The project's subscriptions in this key's environment, optionally one customer's."""
|
|
73
|
+
result: list[SubscriptionResponse] = await self._client.request(list_spec(customer_id))
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
async def get(self, subscription_id: str) -> SubscriptionResponse:
|
|
77
|
+
result: SubscriptionResponse = await self._client.request(get_spec(subscription_id))
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
async def update(
|
|
81
|
+
self, subscription_id: str, body: SubscriptionUpdateRequest, *, idempotency_key: str | None = None
|
|
82
|
+
) -> SubscriptionResponse:
|
|
83
|
+
"""Change status or metadata. Cancel with ``{"status": "canceled"}`` — the record stays for history."""
|
|
84
|
+
result: SubscriptionResponse = await self._client.request(update_spec(subscription_id, body, idempotency_key))
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
async def delete(self, subscription_id: str) -> None:
|
|
88
|
+
"""Remove the record entirely — for test data; prefer cancelling in production."""
|
|
89
|
+
await self._client.request(delete_spec(subscription_id))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Usage events — "this customer just did this thing, this much", matched to a meter by ``event_name``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .._http import RequestSpec, encode_path_segment
|
|
8
|
+
from ..errors import PayloadTooLargeError
|
|
9
|
+
from ..types import UsageEventRequest, UsageEventResponse, UsageSummaryResponse
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..client import AsyncMeterFlow, MeterFlow
|
|
13
|
+
|
|
14
|
+
# The API refuses a batch above this with 413. The SDK checks it before sending so an oversized
|
|
15
|
+
# batch fails instantly, offline, with the same ``PayloadTooLargeError`` — and it does NOT split
|
|
16
|
+
# the batch for you: one call is one request with one idempotency key, and silently turning it
|
|
17
|
+
# into N requests would leave partial success indistinguishable from failure. Chunk at the call
|
|
18
|
+
# site, where you can pick a key per chunk.
|
|
19
|
+
MAX_BATCH_EVENTS = 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def record_spec(body: UsageEventRequest, idempotency_key: str | None) -> RequestSpec:
|
|
23
|
+
return RequestSpec("POST", "usage/events", body=body, idempotency_key=idempotency_key)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def record_batch_spec(events: list[UsageEventRequest], idempotency_key: str | None) -> RequestSpec:
|
|
27
|
+
if len(events) > MAX_BATCH_EVENTS:
|
|
28
|
+
raise PayloadTooLargeError(f"A batch may contain at most {MAX_BATCH_EVENTS} events; this one has {len(events)}. Split it.")
|
|
29
|
+
return RequestSpec("POST", "usage/events/batch", body={"events": events}, idempotency_key=idempotency_key)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summary_spec(customer_id: str, meter_id: str | None, from_: str | None, to: str | None) -> RequestSpec:
|
|
33
|
+
return RequestSpec("GET", f"usage/{encode_path_segment(customer_id)}", query={"meter_id": meter_id, "from_": from_, "to": to})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UsageResource:
|
|
37
|
+
def __init__(self, client: MeterFlow) -> None:
|
|
38
|
+
self._client = client
|
|
39
|
+
|
|
40
|
+
def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
|
|
41
|
+
"""Record one event. Returns immediately (``processed: False``); totals materialise moments later."""
|
|
42
|
+
result: UsageEventResponse = self._client.request(record_spec(body, idempotency_key))
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
|
|
46
|
+
"""Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
|
|
47
|
+
result: list[UsageEventResponse] = self._client.request(record_batch_spec(events, idempotency_key))
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
def summary(
|
|
51
|
+
self, customer_id: str, *, meter_id: str | None = None, from_: str | None = None, to: str | None = None
|
|
52
|
+
) -> UsageSummaryResponse:
|
|
53
|
+
"""A customer's usage broken down by meter. ``from_`` keeps the API's trailing underscore."""
|
|
54
|
+
result: UsageSummaryResponse = self._client.request(summary_spec(customer_id, meter_id, from_, to))
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncUsageResource:
|
|
59
|
+
def __init__(self, client: AsyncMeterFlow) -> None:
|
|
60
|
+
self._client = client
|
|
61
|
+
|
|
62
|
+
async def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
|
|
63
|
+
"""Record one event. Returns immediately (``processed: False``); totals materialise moments later."""
|
|
64
|
+
result: UsageEventResponse = await self._client.request(record_spec(body, idempotency_key))
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
async def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
|
|
68
|
+
"""Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
|
|
69
|
+
result: list[UsageEventResponse] = await self._client.request(record_batch_spec(events, idempotency_key))
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
async def summary(
|
|
73
|
+
self, customer_id: str, *, meter_id: str | None = None, from_: str | None = None, to: str | None = None
|
|
74
|
+
) -> UsageSummaryResponse:
|
|
75
|
+
"""A customer's usage broken down by meter. ``from_`` keeps the API's trailing underscore."""
|
|
76
|
+
result: UsageSummaryResponse = await self._client.request(summary_spec(customer_id, meter_id, from_, to))
|
|
77
|
+
return result
|
meterflow/types.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# GENERATED by scripts/generate_types.py from docs/openapi.json — DO NOT EDIT.
|
|
2
|
+
# OpenAPI version: 0.1.0. Re-run the script after the contract changes; CI fails on drift.
|
|
3
|
+
"""Request and response shapes of the SDK-facing API, as TypedDicts (plain dicts at runtime)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Literal, TypedDict
|
|
8
|
+
|
|
9
|
+
EntitlementReason = Literal["no_subscription", "not_in_plan", "hard_limit_reached", "soft_limit_exceeded", "insufficient_credits"]
|
|
10
|
+
|
|
11
|
+
FeatureType = Literal["boolean", "metered"]
|
|
12
|
+
|
|
13
|
+
SubscriptionStatus = Literal["active", "trialing", "past_due", "paused", "canceled", "expired"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CreditBalanceResponse(TypedDict):
|
|
17
|
+
id: str
|
|
18
|
+
project_id: str
|
|
19
|
+
customer_external_id: str
|
|
20
|
+
balance: str
|
|
21
|
+
total_granted: str
|
|
22
|
+
total_consumed: str
|
|
23
|
+
version: int
|
|
24
|
+
created_at: str
|
|
25
|
+
updated_at: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _CreditDeductRequestRequired(TypedDict):
|
|
29
|
+
customer_external_id: str
|
|
30
|
+
amount: float | str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CreditDeductRequest(_CreditDeductRequestRequired, total=False):
|
|
34
|
+
description: str | None
|
|
35
|
+
idempotency_key: str | None
|
|
36
|
+
metadata: dict[str, Any]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _CreditGrantRequestRequired(TypedDict):
|
|
40
|
+
customer_external_id: str
|
|
41
|
+
amount: float | str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CreditGrantRequest(_CreditGrantRequestRequired, total=False):
|
|
45
|
+
description: str | None
|
|
46
|
+
idempotency_key: str | None
|
|
47
|
+
metadata: dict[str, Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CreditTransactionResponse(TypedDict):
|
|
51
|
+
id: str
|
|
52
|
+
project_id: str
|
|
53
|
+
customer_external_id: str
|
|
54
|
+
transaction_type: str
|
|
55
|
+
amount: str
|
|
56
|
+
balance_before: str
|
|
57
|
+
balance_after: str
|
|
58
|
+
description: str | None
|
|
59
|
+
idempotency_key: str | None
|
|
60
|
+
metadata_: dict[str, Any]
|
|
61
|
+
created_at: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _EntitlementResponseRequired(TypedDict):
|
|
65
|
+
feature: str
|
|
66
|
+
feature_type: FeatureType
|
|
67
|
+
allowed: bool
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EntitlementResponse(_EntitlementResponseRequired, total=False):
|
|
71
|
+
reason: EntitlementReason | None
|
|
72
|
+
limit_type: str | None
|
|
73
|
+
included: int | None
|
|
74
|
+
used: str | None
|
|
75
|
+
remaining: str | None
|
|
76
|
+
period_start: str | None
|
|
77
|
+
period_end: str | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class EntitlementsResponse(TypedDict):
|
|
81
|
+
customer_id: str
|
|
82
|
+
environment: str
|
|
83
|
+
subscription_id: str | None
|
|
84
|
+
plan_id: str | None
|
|
85
|
+
period_end: str | None
|
|
86
|
+
entitlements: list[EntitlementResponse]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PlanMeterLimitResponse(TypedDict):
|
|
90
|
+
id: str
|
|
91
|
+
plan_id: str
|
|
92
|
+
meter_id: str
|
|
93
|
+
included_units: int | None
|
|
94
|
+
overage_rate: str | None
|
|
95
|
+
limit_type: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PlanResponse(TypedDict):
|
|
99
|
+
id: str
|
|
100
|
+
project_id: str
|
|
101
|
+
name: str
|
|
102
|
+
slug: str
|
|
103
|
+
description: str | None
|
|
104
|
+
billing_period: str
|
|
105
|
+
price: str
|
|
106
|
+
currency: str
|
|
107
|
+
trial_days: int
|
|
108
|
+
is_active: bool
|
|
109
|
+
is_public: bool
|
|
110
|
+
metadata_: dict[str, Any]
|
|
111
|
+
meter_limits: list[PlanMeterLimitResponse]
|
|
112
|
+
features: list[str]
|
|
113
|
+
created_at: str
|
|
114
|
+
updated_at: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _SubscriptionCreateRequestRequired(TypedDict):
|
|
118
|
+
plan_id: str
|
|
119
|
+
customer_external_id: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SubscriptionCreateRequest(_SubscriptionCreateRequestRequired, total=False):
|
|
123
|
+
metadata: dict[str, Any]
|
|
124
|
+
idempotency_key: str | None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SubscriptionResponse(TypedDict):
|
|
128
|
+
id: str
|
|
129
|
+
project_id: str
|
|
130
|
+
plan_id: str
|
|
131
|
+
customer_external_id: str
|
|
132
|
+
status: str
|
|
133
|
+
current_period_start: str
|
|
134
|
+
current_period_end: str | None
|
|
135
|
+
trial_end_date: str | None
|
|
136
|
+
canceled_at: str | None
|
|
137
|
+
metadata_: dict[str, Any]
|
|
138
|
+
created_at: str
|
|
139
|
+
updated_at: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SubscriptionUpdateRequest(TypedDict, total=False):
|
|
143
|
+
status: SubscriptionStatus | None
|
|
144
|
+
metadata: dict[str, Any] | None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class UsageEventBatchRequest(TypedDict):
|
|
148
|
+
events: list[UsageEventRequest]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _UsageEventRequestRequired(TypedDict):
|
|
152
|
+
event_name: str
|
|
153
|
+
customer_external_id: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class UsageEventRequest(_UsageEventRequestRequired, total=False):
|
|
157
|
+
value: float | str
|
|
158
|
+
properties: dict[str, Any]
|
|
159
|
+
idempotency_key: str | None
|
|
160
|
+
timestamp: str | None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class UsageEventResponse(TypedDict):
|
|
164
|
+
id: str
|
|
165
|
+
project_id: str
|
|
166
|
+
meter_id: str
|
|
167
|
+
customer_external_id: str
|
|
168
|
+
event_name: str
|
|
169
|
+
value: str
|
|
170
|
+
properties: dict[str, Any]
|
|
171
|
+
idempotency_key: str | None
|
|
172
|
+
processed: bool
|
|
173
|
+
timestamp: str
|
|
174
|
+
created_at: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class UsageSummaryItem(TypedDict):
|
|
178
|
+
meter_id: str
|
|
179
|
+
meter_name: str
|
|
180
|
+
event_name: str
|
|
181
|
+
aggregation_type: str
|
|
182
|
+
value: str
|
|
183
|
+
event_count: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class UsageSummaryResponse(TypedDict):
|
|
187
|
+
project_id: str
|
|
188
|
+
customer_external_id: str
|
|
189
|
+
from_: str | None
|
|
190
|
+
to: str | None
|
|
191
|
+
meters: list[UsageSummaryItem]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = [
|
|
195
|
+
"CreditBalanceResponse",
|
|
196
|
+
"CreditDeductRequest",
|
|
197
|
+
"CreditGrantRequest",
|
|
198
|
+
"CreditTransactionResponse",
|
|
199
|
+
"EntitlementReason",
|
|
200
|
+
"EntitlementResponse",
|
|
201
|
+
"EntitlementsResponse",
|
|
202
|
+
"FeatureType",
|
|
203
|
+
"PlanMeterLimitResponse",
|
|
204
|
+
"PlanResponse",
|
|
205
|
+
"SubscriptionCreateRequest",
|
|
206
|
+
"SubscriptionResponse",
|
|
207
|
+
"SubscriptionStatus",
|
|
208
|
+
"SubscriptionUpdateRequest",
|
|
209
|
+
"UsageEventBatchRequest",
|
|
210
|
+
"UsageEventRequest",
|
|
211
|
+
"UsageEventResponse",
|
|
212
|
+
"UsageSummaryItem",
|
|
213
|
+
"UsageSummaryResponse",
|
|
214
|
+
]
|
meterflow/webhook.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Webhook signature verification — HMAC-SHA256 over the raw body, hex-encoded, timing-safe.
|
|
2
|
+
|
|
3
|
+
Byte-for-byte the same scheme as the Node SDK's ``verifyWebhook`` and the API's signer: a delivery
|
|
4
|
+
signed by MeterFlow verifies here with the webhook's secret, and nothing else does.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_webhook(body: bytes | str, signature: str, secret: str) -> bool:
|
|
14
|
+
"""Return ``True`` when ``signature`` (the ``X-MeterFlow-Signature`` header) matches ``body``.
|
|
15
|
+
|
|
16
|
+
Pass the **raw** request body — re-serialised JSON changes whitespace and key order and will
|
|
17
|
+
not match. Never raises on bad input: an empty, short or malformed signature is simply ``False``.
|
|
18
|
+
"""
|
|
19
|
+
raw = body.encode("utf-8") if isinstance(body, str) else body
|
|
20
|
+
expected = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
|
21
|
+
return hmac.compare_digest(expected, signature)
|