meterbase 0.4.1__tar.gz → 0.5.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.
Files changed (45) hide show
  1. {meterbase-0.4.1 → meterbase-0.5.0}/PKG-INFO +230 -13
  2. {meterbase-0.4.1 → meterbase-0.5.0}/README.md +229 -12
  3. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/_transport.py +20 -8
  4. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/client.py +20 -0
  5. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/__init__.py +4 -0
  6. meterbase-0.5.0/src/meterbase/_async/resources/billing.py +73 -0
  7. meterbase-0.5.0/src/meterbase/_async/resources/purchases.py +73 -0
  8. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_http.py +8 -5
  9. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_models.py +1 -1
  10. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/_transport.py +20 -8
  11. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/client.py +20 -0
  12. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/__init__.py +4 -0
  13. meterbase-0.5.0/src/meterbase/_sync/resources/billing.py +77 -0
  14. meterbase-0.5.0/src/meterbase/_sync/resources/purchases.py +77 -0
  15. meterbase-0.5.0/src/meterbase/_version.py +1 -0
  16. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/errors.py +10 -5
  17. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/resources.py +8 -0
  18. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/types.py +187 -0
  19. meterbase-0.4.1/src/meterbase/_version.py +0 -1
  20. {meterbase-0.4.1 → meterbase-0.5.0}/.gitignore +0 -0
  21. {meterbase-0.4.1 → meterbase-0.5.0}/LICENSE +0 -0
  22. {meterbase-0.4.1 → meterbase-0.5.0}/pyproject.toml +0 -0
  23. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/__init__.py +0 -0
  24. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/__init__.py +0 -0
  25. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/_io.py +0 -0
  26. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/customer_allowances.py +0 -0
  27. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
  28. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/customer_plan.py +0 -0
  29. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/customers.py +0 -0
  30. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/meters.py +0 -0
  31. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_async/resources/plans.py +0 -0
  32. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_json.py +0 -0
  33. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_keys.py +0 -0
  34. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_not_given.py +0 -0
  35. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/__init__.py +0 -0
  36. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/_io.py +0 -0
  37. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/customer_allowances.py +0 -0
  38. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
  39. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/customer_plan.py +0 -0
  40. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/customers.py +0 -0
  41. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/meters.py +0 -0
  42. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_sync/resources/plans.py +0 -0
  43. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/_time.py +0 -0
  44. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/py.typed +0 -0
  45. {meterbase-0.4.1 → meterbase-0.5.0}/src/meterbase/webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meterbase
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Python SDK for the Meterbase engine
5
5
  Project-URL: Homepage, https://meterbase.tech
6
6
  Project-URL: Documentation, https://meterbase.tech/docs
@@ -750,6 +750,213 @@ for grant in paid.data: # the grant the payment made, or none
750
750
  meterbase.customers.allowances.revoke(customer.id, grant.id)
751
751
  ```
752
752
 
753
+ ## Billing
754
+
755
+ Once the workspace has connected Stripe or Dodo Payments under **Payments** in
756
+ the dashboard, and bound its plans and packs to Stripe prices or Dodo products,
757
+ Meterbase sells them: it opens the checkout, receives the provider's webhooks,
758
+ and moves the customer's plan or grants the pack. There is no webhook handler of
759
+ yours in between. These calls go to the service at
760
+ `https://service.meterbase.tech` rather than the engine, with the same API key;
761
+ see [Options](#options). There each API key has three limits, whichever
762
+ customers the calls are for: `billing.checkout` and `billing.state` have 30
763
+ requests per 10 s each, and `purchases` has 120 per 10 s. All calls from one IP
764
+ address share 300 per 10 s on top. A 429 there raises `RateLimitError` with
765
+ `retry_after`, once the retries a `GET` or a keyed checkout gets have run out;
766
+ `purchases.wait` keeps waiting instead.
767
+
768
+ A Dodo Payments subscription made outside Meterbase's checkout, from a buy link
769
+ or your own server, is held until you link it to its customer once under
770
+ **Payments**: anyone with a buy link can name a customer, so Meterbase trusts
771
+ only a name its own checkout signed. Give each Dodo subscription product a
772
+ Subscription Period longer than its billing period, such as 10 years: with the
773
+ two equal, Dodo Payments ends the subscription after one charge.
774
+
775
+ ### Checkout
776
+
777
+ ```python
778
+ session = meterbase.billing.checkout(
779
+ "acct_1", # the tenant's own id
780
+ plan="pro", # the plan's key; or pack="tokens_200k", quantity=2
781
+ success_url="https://app.example.com/billing/done",
782
+ cancel_url="https://app.example.com/pricing",
783
+ )
784
+ redirect(session.url)
785
+ ```
786
+
787
+ Give exactly one of `plan` or `pack`, and `quantity` only with a pack. A pack is
788
+ named as the pack offers from [`billing.state`](#a-customers-billing) name it:
789
+ by its price's lookup key, or by the price id when it has none. Dodo Payments
790
+ has no lookup keys, so there it is the product id, which differs between test
791
+ and live mode: take it from the offer, chosen by
792
+ [what it grants](#a-customers-billing). A plan bound at more than one interval
793
+ sells its first bound price, unless `price` names another: a plan offer's
794
+ `price.id` or `price.lookup_key`. `checkout` reuses the provider's customer
795
+ already linked to the customer, and a customer Meterbase has not seen yet is
796
+ created when their purchase is applied. `success_url` is https, or http on
797
+ localhost; Meterbase adds `session_id`, its own opaque id for the checkout, to
798
+ it. The session expires at `session.expires_at`: an hour after it opens on
799
+ Stripe, and 24 hours on Dodo Payments.
800
+
801
+ A plan's checkout opens a new subscription, so it is for a customer without a
802
+ paying one. For a customer whose subscription pays and is not cancelling
803
+ (`billing.status` is `active`, `trialing` or `past_due` in their
804
+ [state](#a-customers-billing), and `cancel_at_period_end` is false), it raises
805
+ `ConflictError` with `code == "already_subscribed"`. There is no plan-change
806
+ call yet: a customer who already pays changes plan or interval in the provider's
807
+ customer portal, so link there for their `upgrade`, `downgrade` and
808
+ `switch_interval` offers. A customer whose subscription is cancelling can check
809
+ out a new one, which takes over when the old one ends; its purchase reads `held`
810
+ until then.
811
+
812
+ Set the portal up once per mode, on the provider's side, and give it only the
813
+ prices or products you bound: a subscription moved onto any other is held for
814
+ review under **Payments**.
815
+
816
+ - **Stripe:** under Settings → Billing → Customer portal, turn on **Switch
817
+ plan**, add the bound products and prices, and save. Then **Activate link**,
818
+ and link to the login link it gives (`https://billing.stripe.com/p/login/…`),
819
+ where the customer signs in with the email they paid with. In test mode,
820
+ open a customer's portal from their page in the Stripe Dashboard.
821
+ - **Dodo Payments:** put the bound subscription products in one Product
822
+ Collection, since the portal changes plans only within one, and turn on
823
+ **Allow Subscription Updates** under Settings → Subscriptions. Link to
824
+ `https://customer.dodopayments.com/login/{business_id}`, where the customer
825
+ asks for a sign-in link by the email they paid with. Test mode sends no
826
+ sign-in emails, so there your server makes a session with your Dodo key:
827
+ `POST /customers/{customer_id}/customer-portal/session`, with the
828
+ `customer.customer_id` of `GET /subscriptions/{id}`, for the
829
+ `billing.subscription_id` in their [state](#a-customers-billing).
830
+
831
+ A checkout is never retried unless it is given an `idempotency_key`, 1 to 200
832
+ printable ASCII characters with no spaces. Until the session expires, a repeat
833
+ with the same key answers that session rather than opening another when it
834
+ names the same customer, plan or pack, price and quantity, and raises
835
+ `IdempotencyKeyReusedError` (409 `idempotency_key_reused`) otherwise. The return
836
+ URLs stay the first request's. Once the session expires, the key opens a new
837
+ one. What each call can raise is under [Billing errors](#billing-errors).
838
+
839
+ ### The return page
840
+
841
+ The provider can send the customer back before its webhook has arrived, so the
842
+ return page waits for the purchase to land. Dodo Payments adds query parameters
843
+ of its own, such as `status`; they prove nothing about payment, so read the
844
+ purchase by `session_id`:
845
+
846
+ ```python
847
+ purchase = meterbase.purchases.wait(request.args["session_id"], timeout=15)
848
+
849
+ if purchase is None:
850
+ ... # no checkout has that session_id
851
+ elif purchase.state in ("waiting", "settling"):
852
+ ... # still on its way: show "Confirming your payment"
853
+ elif purchase.state in ("fulfilled", "late", "held"):
854
+ ... # paid; only "fulfilled" means the plan or the pack is in place
855
+ else:
856
+ ... # "failed" or "expired": nothing was bought
857
+ ```
858
+
859
+ | `state` | Meaning |
860
+ | ----------- | ------------------------------------------------------------------------- |
861
+ | `waiting` | the checkout is open, or it was paid and is not applied yet |
862
+ | `settling` | paid by a method that clears later, such as a bank debit |
863
+ | `fulfilled` | the plan or the pack is in place |
864
+ | `late` | paid over 90 s ago and still not applied: the customer can close the page |
865
+ | `held` | paid, and held for review under **Payments** in the dashboard |
866
+ | `failed` | the payment failed |
867
+ | `expired` | the checkout closed unpaid |
868
+
869
+ `wait` reads the purchase every `interval` seconds, 2 unless set, for up to
870
+ `timeout` seconds, 90 unless set, and returns as soon as the state is anything
871
+ but `waiting` or `settling`. Running out of time is not an error: it returns
872
+ the last read, so show a purchase still pending as pending, and read it again
873
+ from the page. Many hosts cut a request off before 90 s, so a wait inside the
874
+ page's request passes a shorter `timeout`, as above. It returns `None` when the
875
+ purchase was never found, which means no checkout has that id, as with an
876
+ edited link. A 404 reads as waiting, in case the provider does not show a new
877
+ session yet. A `RateLimitError` reads as waiting too, with the next read after
878
+ its `retry_after` when that is longer than `interval`, since every return page
879
+ on one key shares the 120 reads per 10 s.
880
+ `timeout` bounds the polling, not each read, which keeps the client's own
881
+ timeout and retries. A negative or NaN `timeout`, an `interval` that is not
882
+ above 0, or an empty session id raises `MeterbaseError` and sends nothing.
883
+ `purchases.retrieve` reads it once.
884
+
885
+ ### A customer's billing
886
+
887
+ ```python
888
+ state = meterbase.billing.state("acct_1")
889
+
890
+ state.plan # BillingPlan(key="pro", name="Pro"), or None
891
+ if state.billing and state.billing.status == "past_due":
892
+ print("Payment failing, next retry:", state.billing.next_retry_at) # None on Dodo
893
+ for offer in state.offers:
894
+ print(offer.action, offer.plan or offer.pack, offer.price.unit_amount)
895
+ ```
896
+
897
+ One read for a pricing or account page: the plan and its `period`, a change
898
+ waiting to land (`pending_change`), each meter the plan meters (`allowances`),
899
+ the open grants beside it, purchased packs included (`grants`), the provider's
900
+ subscription the plan follows (`billing`, `None` when there is none), and every
901
+ bound price on offer. A customer Meterbase has not seen yet reads as one with no
902
+ plan, every plan offer an `upgrade`, so a pricing page can show a new visitor
903
+ what to buy before they buy. A customer that was deleted raises `NotFoundError`
904
+ (404 `customer_not_found`).
905
+
906
+ `billing.status` reads the same on both providers, with a cancelled or expired
907
+ subscription `ended`. Dodo Payments has no `trialing`, since a trial reads
908
+ `active`, and its `on_hold` reads as `unpaid`; `next_retry_at` and `lookup_key`
909
+ are always `None` there.
910
+
911
+ An offer's `action` is `current`, `switch_interval` for the same plan billed
912
+ over another interval, `upgrade` or `downgrade` by price per month against the
913
+ current plan, or `purchase` for a pack. A plan offer is sold to a customer
914
+ without a paying subscription with `checkout(..., plan=offer.plan,
915
+ price=offer.price.id)`, and a pack offer with `pack=offer.pack`. New actions can
916
+ be added; treat one you do not know as unavailable. `price.unit_amount` is the
917
+ price as listed, in the currency's minor unit, cents for USD. Dodo Payments adds
918
+ tax at checkout, by the buyer's location, unless the product's price is tax
919
+ inclusive, so a Dodo Payments buyer can pay more than the offer shows.
920
+
921
+ A pack offer's `grant` is what one pack gives: `amount` on the meter whose key
922
+ is `meter`, twice that for `quantity=2`, expiring `expires_days` after the
923
+ payment, or never when that is `None`. A plan offer's `grant` is `None`. Choose
924
+ a pack by its grant rather than by name, telling two packs on one meter apart
925
+ by `amount`: Dodo Payments has no lookup keys, and its product ids differ
926
+ between test and live mode.
927
+
928
+ ```python
929
+ top_up = next(
930
+ (o for o in state.offers if o.grant and o.grant.meter == "ai_tokens"), None
931
+ )
932
+ # top_up.grant == {"meter": "ai_tokens", "amount": 200_000, "expires_days": 90}
933
+
934
+ if top_up and top_up.pack:
935
+ session = meterbase.billing.checkout(
936
+ "acct_1",
937
+ pack=top_up.pack,
938
+ success_url="https://app.example.com/billing/done",
939
+ )
940
+ ```
941
+
942
+ ### Billing errors
943
+
944
+ | `code` | Raises | Calls | When |
945
+ | ------------------------ | -------------------------------- | ------------------- | ------------------------------------------------------------------------ |
946
+ | `unauthenticated` | `AuthenticationError`, 401 | all | unknown, revoked or malformed key |
947
+ | `payments_not_connected` | `ConflictError`, 409 | all | the workspace has no payment provider connected |
948
+ | `already_subscribed` | `ConflictError`, 409 | `checkout` | a plan checkout for a customer who already pays |
949
+ | `customer_deleted` | `ConflictError`, 409 | `checkout` | the customer was deleted |
950
+ | `idempotency_key_reused` | `IdempotencyKeyReusedError`, 409 | `checkout` | the `idempotency_key` was used for a different checkout |
951
+ | `customer_not_found` | `NotFoundError`, 404 | `state` | the customer was deleted |
952
+ | `purchase_not_found` | `NotFoundError`, 404 | `purchases` | no checkout has that session id; `wait` reads it as waiting |
953
+ | `price_not_bound` | `InvalidRequestError`, 422 | `checkout` | the plan or pack has nothing bound, or `price` is not bound to the plan |
954
+ | `invalid_request` | `InvalidRequestError`, 422 | `checkout`, `state` | a field failed validation; the message names it |
955
+ | `rate_limited` | `RateLimitError`, 429 | all | past a [limit](#billing), with `retry_after`; `wait` keeps waiting |
956
+ | `provider_error` | `ServerError`, 502 | all | the provider refused or failed; the message says which |
957
+ | `unavailable` | `ServerError`, 503 | all | the connected key could not be opened: disconnect, then connect it again |
958
+ | `engine_unavailable` | `ServerError`, 503 | all | Meterbase could not be reached |
959
+
753
960
  ## Webhooks
754
961
 
755
962
  Meterbase posts to your endpoints when a customer crosses a usage threshold,
@@ -920,13 +1127,13 @@ except RateLimitError as error:
920
1127
  | `NotFoundError` | 404 |
921
1128
  | `ConflictError` | 409 — a key or external id is taken |
922
1129
  | `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
923
- | `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
1130
+ | `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant, a plan change or a `billing.checkout` was already used for a different request. No grant or plan change was written, and no checkout opened |
924
1131
  | `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
925
1132
  | `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
926
1133
  | `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
927
1134
  | `TooLateError` | 422 — an `InvalidRequestError`: the key is over an hour from the engine's clock, which it carries on `server_time`. Nothing was recorded |
928
1135
  | `ReservationMismatchError` | 422 — an `InvalidRequestError`: the reservation was made for a different customer or meter |
929
- | `RateLimitError` | 429 — `check` or `reserve` past the workspace's fair-use rate, `code == "rate_limited"`, with `retry_after` in seconds. `reserve` retries it for you; `check` raises at once, so fail open or closed |
1136
+ | `RateLimitError` | 429 — `check` or `reserve` past the workspace's fair-use rate, `code == "rate_limited"`, with `retry_after` in seconds. `reserve` retries it for you; `check` raises at once, so fail open or closed. Also a billing call past its [per-key limit](#billing), once a `GET` or a keyed checkout has retried it; `purchases.wait` keeps waiting |
930
1137
  | `ServerError` | 5xx |
931
1138
  | `APIConnectionError` / `APITimeoutError` | the request never got an answer |
932
1139
  | `WebhookVerificationError` | `verify_webhook`: not a delivery Meterbase signed in the last five minutes |
@@ -945,7 +1152,8 @@ SDK would raise for a response, for realistic errors in your own tests.
945
1152
  ```python
946
1153
  Meterbase(
947
1154
  api_key="mb_sk_live_…",
948
- base_url="https://api.meterbase.dev",
1155
+ base_url="https://api.meterbase.tech",
1156
+ billing_base_url="https://service.meterbase.tech", # billing and purchases
949
1157
  timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
950
1158
  max_retries=2,
951
1159
  http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
@@ -959,7 +1167,9 @@ milliseconds when the engine is well. Decide what an `APITimeoutError` means
959
1167
  there — failing open or failing closed — rather than leaving the user waiting
960
1168
  out the 35 s the writes get. Pass an `http_client` for proxies,
961
1169
  limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
962
- `httpx.AsyncClient` for `AsyncMeterbase`.
1170
+ `httpx.AsyncClient` for `AsyncMeterbase`. `billing` and `purchases` go to
1171
+ `billing_base_url`, `https://service.meterbase.tech` unless set, and every other
1172
+ call to `base_url`, over the same connections.
963
1173
 
964
1174
  For other settings on some calls, derive a client with them. It shares the
965
1175
  original's connections, and its clock:
@@ -978,14 +1188,15 @@ cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
978
1188
  bound it with `timeout` and `max_retries` instead.
979
1189
 
980
1190
  **Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
981
- and a grant or plan change given an `idempotency_key`, on connection failures,
982
- timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
983
- `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
984
- other `POST` and `PATCH` is never replayed, because a retried create could
985
- produce a second row. An `ensure` can be, because running it twice changes
986
- nothing: one whose first attempt landed answers `created` false. So can a keyed
987
- grant or plan change, which the engine answers with what the first attempt
988
- wrote: one whose first attempt landed answers `replayed` true.
1191
+ and a grant, plan change or `billing.checkout` given an `idempotency_key`, on
1192
+ connection failures, timeouts, 408, 429 and 5xx, with exponential backoff and
1193
+ full jitter. `Retry-After` is honoured when Meterbase sends it, up to eight
1194
+ seconds. Every other `POST` and `PATCH` is never replayed, because a retried
1195
+ create could produce a second row. An `ensure` can be, because running it twice
1196
+ changes nothing: one whose first attempt landed answers `created` false. So can
1197
+ a keyed grant or plan change, which the engine answers with what the first
1198
+ attempt wrote: one whose first attempt landed answers `replayed` true. A keyed
1199
+ checkout is answered by Meterbase with the session its first attempt opened.
989
1200
 
990
1201
  `track` and `reserve` are the exception because they carry an idempotency key,
991
1202
  and because the SDK fixes that key **before** the retry loop starts: every
@@ -1014,6 +1225,12 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
1014
1225
  change that will not run against your code cannot arrive as a patch. The SDK
1015
1226
  follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
1016
1227
 
1228
+ **0.5.0** adds `billing` and `purchases`, for a workspace that sells through
1229
+ Stripe or Dodo Payments: `billing.checkout`, `billing.state`,
1230
+ `purchases.retrieve` and `purchases.wait`, with the `billing_base_url` option.
1231
+ They go to the service at `https://service.meterbase.tech` rather than the
1232
+ engine. Nothing in it breaks code written for 0.4.0.
1233
+
1017
1234
  **0.4.0** pages the customer list, and adds `customers.list_all` and the
1018
1235
  `limit`, `starting_after`, `plan` and `has_plan` parameters. One change can break
1019
1236
  code: `customers.list()` answers one page of 100, newest first, where it used to
@@ -723,6 +723,213 @@ for grant in paid.data: # the grant the payment made, or none
723
723
  meterbase.customers.allowances.revoke(customer.id, grant.id)
724
724
  ```
725
725
 
726
+ ## Billing
727
+
728
+ Once the workspace has connected Stripe or Dodo Payments under **Payments** in
729
+ the dashboard, and bound its plans and packs to Stripe prices or Dodo products,
730
+ Meterbase sells them: it opens the checkout, receives the provider's webhooks,
731
+ and moves the customer's plan or grants the pack. There is no webhook handler of
732
+ yours in between. These calls go to the service at
733
+ `https://service.meterbase.tech` rather than the engine, with the same API key;
734
+ see [Options](#options). There each API key has three limits, whichever
735
+ customers the calls are for: `billing.checkout` and `billing.state` have 30
736
+ requests per 10 s each, and `purchases` has 120 per 10 s. All calls from one IP
737
+ address share 300 per 10 s on top. A 429 there raises `RateLimitError` with
738
+ `retry_after`, once the retries a `GET` or a keyed checkout gets have run out;
739
+ `purchases.wait` keeps waiting instead.
740
+
741
+ A Dodo Payments subscription made outside Meterbase's checkout, from a buy link
742
+ or your own server, is held until you link it to its customer once under
743
+ **Payments**: anyone with a buy link can name a customer, so Meterbase trusts
744
+ only a name its own checkout signed. Give each Dodo subscription product a
745
+ Subscription Period longer than its billing period, such as 10 years: with the
746
+ two equal, Dodo Payments ends the subscription after one charge.
747
+
748
+ ### Checkout
749
+
750
+ ```python
751
+ session = meterbase.billing.checkout(
752
+ "acct_1", # the tenant's own id
753
+ plan="pro", # the plan's key; or pack="tokens_200k", quantity=2
754
+ success_url="https://app.example.com/billing/done",
755
+ cancel_url="https://app.example.com/pricing",
756
+ )
757
+ redirect(session.url)
758
+ ```
759
+
760
+ Give exactly one of `plan` or `pack`, and `quantity` only with a pack. A pack is
761
+ named as the pack offers from [`billing.state`](#a-customers-billing) name it:
762
+ by its price's lookup key, or by the price id when it has none. Dodo Payments
763
+ has no lookup keys, so there it is the product id, which differs between test
764
+ and live mode: take it from the offer, chosen by
765
+ [what it grants](#a-customers-billing). A plan bound at more than one interval
766
+ sells its first bound price, unless `price` names another: a plan offer's
767
+ `price.id` or `price.lookup_key`. `checkout` reuses the provider's customer
768
+ already linked to the customer, and a customer Meterbase has not seen yet is
769
+ created when their purchase is applied. `success_url` is https, or http on
770
+ localhost; Meterbase adds `session_id`, its own opaque id for the checkout, to
771
+ it. The session expires at `session.expires_at`: an hour after it opens on
772
+ Stripe, and 24 hours on Dodo Payments.
773
+
774
+ A plan's checkout opens a new subscription, so it is for a customer without a
775
+ paying one. For a customer whose subscription pays and is not cancelling
776
+ (`billing.status` is `active`, `trialing` or `past_due` in their
777
+ [state](#a-customers-billing), and `cancel_at_period_end` is false), it raises
778
+ `ConflictError` with `code == "already_subscribed"`. There is no plan-change
779
+ call yet: a customer who already pays changes plan or interval in the provider's
780
+ customer portal, so link there for their `upgrade`, `downgrade` and
781
+ `switch_interval` offers. A customer whose subscription is cancelling can check
782
+ out a new one, which takes over when the old one ends; its purchase reads `held`
783
+ until then.
784
+
785
+ Set the portal up once per mode, on the provider's side, and give it only the
786
+ prices or products you bound: a subscription moved onto any other is held for
787
+ review under **Payments**.
788
+
789
+ - **Stripe:** under Settings → Billing → Customer portal, turn on **Switch
790
+ plan**, add the bound products and prices, and save. Then **Activate link**,
791
+ and link to the login link it gives (`https://billing.stripe.com/p/login/…`),
792
+ where the customer signs in with the email they paid with. In test mode,
793
+ open a customer's portal from their page in the Stripe Dashboard.
794
+ - **Dodo Payments:** put the bound subscription products in one Product
795
+ Collection, since the portal changes plans only within one, and turn on
796
+ **Allow Subscription Updates** under Settings → Subscriptions. Link to
797
+ `https://customer.dodopayments.com/login/{business_id}`, where the customer
798
+ asks for a sign-in link by the email they paid with. Test mode sends no
799
+ sign-in emails, so there your server makes a session with your Dodo key:
800
+ `POST /customers/{customer_id}/customer-portal/session`, with the
801
+ `customer.customer_id` of `GET /subscriptions/{id}`, for the
802
+ `billing.subscription_id` in their [state](#a-customers-billing).
803
+
804
+ A checkout is never retried unless it is given an `idempotency_key`, 1 to 200
805
+ printable ASCII characters with no spaces. Until the session expires, a repeat
806
+ with the same key answers that session rather than opening another when it
807
+ names the same customer, plan or pack, price and quantity, and raises
808
+ `IdempotencyKeyReusedError` (409 `idempotency_key_reused`) otherwise. The return
809
+ URLs stay the first request's. Once the session expires, the key opens a new
810
+ one. What each call can raise is under [Billing errors](#billing-errors).
811
+
812
+ ### The return page
813
+
814
+ The provider can send the customer back before its webhook has arrived, so the
815
+ return page waits for the purchase to land. Dodo Payments adds query parameters
816
+ of its own, such as `status`; they prove nothing about payment, so read the
817
+ purchase by `session_id`:
818
+
819
+ ```python
820
+ purchase = meterbase.purchases.wait(request.args["session_id"], timeout=15)
821
+
822
+ if purchase is None:
823
+ ... # no checkout has that session_id
824
+ elif purchase.state in ("waiting", "settling"):
825
+ ... # still on its way: show "Confirming your payment"
826
+ elif purchase.state in ("fulfilled", "late", "held"):
827
+ ... # paid; only "fulfilled" means the plan or the pack is in place
828
+ else:
829
+ ... # "failed" or "expired": nothing was bought
830
+ ```
831
+
832
+ | `state` | Meaning |
833
+ | ----------- | ------------------------------------------------------------------------- |
834
+ | `waiting` | the checkout is open, or it was paid and is not applied yet |
835
+ | `settling` | paid by a method that clears later, such as a bank debit |
836
+ | `fulfilled` | the plan or the pack is in place |
837
+ | `late` | paid over 90 s ago and still not applied: the customer can close the page |
838
+ | `held` | paid, and held for review under **Payments** in the dashboard |
839
+ | `failed` | the payment failed |
840
+ | `expired` | the checkout closed unpaid |
841
+
842
+ `wait` reads the purchase every `interval` seconds, 2 unless set, for up to
843
+ `timeout` seconds, 90 unless set, and returns as soon as the state is anything
844
+ but `waiting` or `settling`. Running out of time is not an error: it returns
845
+ the last read, so show a purchase still pending as pending, and read it again
846
+ from the page. Many hosts cut a request off before 90 s, so a wait inside the
847
+ page's request passes a shorter `timeout`, as above. It returns `None` when the
848
+ purchase was never found, which means no checkout has that id, as with an
849
+ edited link. A 404 reads as waiting, in case the provider does not show a new
850
+ session yet. A `RateLimitError` reads as waiting too, with the next read after
851
+ its `retry_after` when that is longer than `interval`, since every return page
852
+ on one key shares the 120 reads per 10 s.
853
+ `timeout` bounds the polling, not each read, which keeps the client's own
854
+ timeout and retries. A negative or NaN `timeout`, an `interval` that is not
855
+ above 0, or an empty session id raises `MeterbaseError` and sends nothing.
856
+ `purchases.retrieve` reads it once.
857
+
858
+ ### A customer's billing
859
+
860
+ ```python
861
+ state = meterbase.billing.state("acct_1")
862
+
863
+ state.plan # BillingPlan(key="pro", name="Pro"), or None
864
+ if state.billing and state.billing.status == "past_due":
865
+ print("Payment failing, next retry:", state.billing.next_retry_at) # None on Dodo
866
+ for offer in state.offers:
867
+ print(offer.action, offer.plan or offer.pack, offer.price.unit_amount)
868
+ ```
869
+
870
+ One read for a pricing or account page: the plan and its `period`, a change
871
+ waiting to land (`pending_change`), each meter the plan meters (`allowances`),
872
+ the open grants beside it, purchased packs included (`grants`), the provider's
873
+ subscription the plan follows (`billing`, `None` when there is none), and every
874
+ bound price on offer. A customer Meterbase has not seen yet reads as one with no
875
+ plan, every plan offer an `upgrade`, so a pricing page can show a new visitor
876
+ what to buy before they buy. A customer that was deleted raises `NotFoundError`
877
+ (404 `customer_not_found`).
878
+
879
+ `billing.status` reads the same on both providers, with a cancelled or expired
880
+ subscription `ended`. Dodo Payments has no `trialing`, since a trial reads
881
+ `active`, and its `on_hold` reads as `unpaid`; `next_retry_at` and `lookup_key`
882
+ are always `None` there.
883
+
884
+ An offer's `action` is `current`, `switch_interval` for the same plan billed
885
+ over another interval, `upgrade` or `downgrade` by price per month against the
886
+ current plan, or `purchase` for a pack. A plan offer is sold to a customer
887
+ without a paying subscription with `checkout(..., plan=offer.plan,
888
+ price=offer.price.id)`, and a pack offer with `pack=offer.pack`. New actions can
889
+ be added; treat one you do not know as unavailable. `price.unit_amount` is the
890
+ price as listed, in the currency's minor unit, cents for USD. Dodo Payments adds
891
+ tax at checkout, by the buyer's location, unless the product's price is tax
892
+ inclusive, so a Dodo Payments buyer can pay more than the offer shows.
893
+
894
+ A pack offer's `grant` is what one pack gives: `amount` on the meter whose key
895
+ is `meter`, twice that for `quantity=2`, expiring `expires_days` after the
896
+ payment, or never when that is `None`. A plan offer's `grant` is `None`. Choose
897
+ a pack by its grant rather than by name, telling two packs on one meter apart
898
+ by `amount`: Dodo Payments has no lookup keys, and its product ids differ
899
+ between test and live mode.
900
+
901
+ ```python
902
+ top_up = next(
903
+ (o for o in state.offers if o.grant and o.grant.meter == "ai_tokens"), None
904
+ )
905
+ # top_up.grant == {"meter": "ai_tokens", "amount": 200_000, "expires_days": 90}
906
+
907
+ if top_up and top_up.pack:
908
+ session = meterbase.billing.checkout(
909
+ "acct_1",
910
+ pack=top_up.pack,
911
+ success_url="https://app.example.com/billing/done",
912
+ )
913
+ ```
914
+
915
+ ### Billing errors
916
+
917
+ | `code` | Raises | Calls | When |
918
+ | ------------------------ | -------------------------------- | ------------------- | ------------------------------------------------------------------------ |
919
+ | `unauthenticated` | `AuthenticationError`, 401 | all | unknown, revoked or malformed key |
920
+ | `payments_not_connected` | `ConflictError`, 409 | all | the workspace has no payment provider connected |
921
+ | `already_subscribed` | `ConflictError`, 409 | `checkout` | a plan checkout for a customer who already pays |
922
+ | `customer_deleted` | `ConflictError`, 409 | `checkout` | the customer was deleted |
923
+ | `idempotency_key_reused` | `IdempotencyKeyReusedError`, 409 | `checkout` | the `idempotency_key` was used for a different checkout |
924
+ | `customer_not_found` | `NotFoundError`, 404 | `state` | the customer was deleted |
925
+ | `purchase_not_found` | `NotFoundError`, 404 | `purchases` | no checkout has that session id; `wait` reads it as waiting |
926
+ | `price_not_bound` | `InvalidRequestError`, 422 | `checkout` | the plan or pack has nothing bound, or `price` is not bound to the plan |
927
+ | `invalid_request` | `InvalidRequestError`, 422 | `checkout`, `state` | a field failed validation; the message names it |
928
+ | `rate_limited` | `RateLimitError`, 429 | all | past a [limit](#billing), with `retry_after`; `wait` keeps waiting |
929
+ | `provider_error` | `ServerError`, 502 | all | the provider refused or failed; the message says which |
930
+ | `unavailable` | `ServerError`, 503 | all | the connected key could not be opened: disconnect, then connect it again |
931
+ | `engine_unavailable` | `ServerError`, 503 | all | Meterbase could not be reached |
932
+
726
933
  ## Webhooks
727
934
 
728
935
  Meterbase posts to your endpoints when a customer crosses a usage threshold,
@@ -893,13 +1100,13 @@ except RateLimitError as error:
893
1100
  | `NotFoundError` | 404 |
894
1101
  | `ConflictError` | 409 — a key or external id is taken |
895
1102
  | `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
896
- | `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant or plan change was already used for a different request. Nothing was written |
1103
+ | `IdempotencyKeyReusedError` | 409 — a `ConflictError`: the `idempotency_key` on a grant, a plan change or a `billing.checkout` was already used for a different request. No grant or plan change was written, and no checkout opened |
897
1104
  | `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
898
1105
  | `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
899
1106
  | `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
900
1107
  | `TooLateError` | 422 — an `InvalidRequestError`: the key is over an hour from the engine's clock, which it carries on `server_time`. Nothing was recorded |
901
1108
  | `ReservationMismatchError` | 422 — an `InvalidRequestError`: the reservation was made for a different customer or meter |
902
- | `RateLimitError` | 429 — `check` or `reserve` past the workspace's fair-use rate, `code == "rate_limited"`, with `retry_after` in seconds. `reserve` retries it for you; `check` raises at once, so fail open or closed |
1109
+ | `RateLimitError` | 429 — `check` or `reserve` past the workspace's fair-use rate, `code == "rate_limited"`, with `retry_after` in seconds. `reserve` retries it for you; `check` raises at once, so fail open or closed. Also a billing call past its [per-key limit](#billing), once a `GET` or a keyed checkout has retried it; `purchases.wait` keeps waiting |
903
1110
  | `ServerError` | 5xx |
904
1111
  | `APIConnectionError` / `APITimeoutError` | the request never got an answer |
905
1112
  | `WebhookVerificationError` | `verify_webhook`: not a delivery Meterbase signed in the last five minutes |
@@ -918,7 +1125,8 @@ SDK would raise for a response, for realistic errors in your own tests.
918
1125
  ```python
919
1126
  Meterbase(
920
1127
  api_key="mb_sk_live_…",
921
- base_url="https://api.meterbase.dev",
1128
+ base_url="https://api.meterbase.tech",
1129
+ billing_base_url="https://service.meterbase.tech", # billing and purchases
922
1130
  timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
923
1131
  max_retries=2,
924
1132
  http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
@@ -932,7 +1140,9 @@ milliseconds when the engine is well. Decide what an `APITimeoutError` means
932
1140
  there — failing open or failing closed — rather than leaving the user waiting
933
1141
  out the 35 s the writes get. Pass an `http_client` for proxies,
934
1142
  limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
935
- `httpx.AsyncClient` for `AsyncMeterbase`.
1143
+ `httpx.AsyncClient` for `AsyncMeterbase`. `billing` and `purchases` go to
1144
+ `billing_base_url`, `https://service.meterbase.tech` unless set, and every other
1145
+ call to `base_url`, over the same connections.
936
1146
 
937
1147
  For other settings on some calls, derive a client with them. It shares the
938
1148
  original's connections, and its clock:
@@ -951,14 +1161,15 @@ cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
951
1161
  bound it with `timeout` and `max_retries` instead.
952
1162
 
953
1163
  **Retries** apply to `GET`, `DELETE`, `track`, `reserve`, `customers.ensure`,
954
- and a grant or plan change given an `idempotency_key`, on connection failures,
955
- timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
956
- `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
957
- other `POST` and `PATCH` is never replayed, because a retried create could
958
- produce a second row. An `ensure` can be, because running it twice changes
959
- nothing: one whose first attempt landed answers `created` false. So can a keyed
960
- grant or plan change, which the engine answers with what the first attempt
961
- wrote: one whose first attempt landed answers `replayed` true.
1164
+ and a grant, plan change or `billing.checkout` given an `idempotency_key`, on
1165
+ connection failures, timeouts, 408, 429 and 5xx, with exponential backoff and
1166
+ full jitter. `Retry-After` is honoured when Meterbase sends it, up to eight
1167
+ seconds. Every other `POST` and `PATCH` is never replayed, because a retried
1168
+ create could produce a second row. An `ensure` can be, because running it twice
1169
+ changes nothing: one whose first attempt landed answers `created` false. So can
1170
+ a keyed grant or plan change, which the engine answers with what the first
1171
+ attempt wrote: one whose first attempt landed answers `replayed` true. A keyed
1172
+ checkout is answered by Meterbase with the session its first attempt opened.
962
1173
 
963
1174
  `track` and `reserve` are the exception because they carry an idempotency key,
964
1175
  and because the SDK fixes that key **before** the retry loop starts: every
@@ -987,6 +1198,12 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
987
1198
  change that will not run against your code cannot arrive as a patch. The SDK
988
1199
  follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
989
1200
 
1201
+ **0.5.0** adds `billing` and `purchases`, for a workspace that sells through
1202
+ Stripe or Dodo Payments: `billing.checkout`, `billing.state`,
1203
+ `purchases.retrieve` and `purchases.wait`, with the `billing_base_url` option.
1204
+ They go to the service at `https://service.meterbase.tech` rather than the
1205
+ engine. Nothing in it breaks code written for 0.4.0.
1206
+
990
1207
  **0.4.0** pages the customer list, and adds `customers.list_all` and the
991
1208
  `limit`, `starting_after`, `plan` and `has_plan` parameters. One change can break
992
1209
  code: `customers.list()` answers one page of 100, newest first, where it used to