> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kelviq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Python SDK

> Changelog for kelviq-sdk (Python)

<Update label="v2.7.3" description="July 21, 2026">
  ### New Features

  #### Activate Subscription Updates After Payment

  The synchronous and asynchronous `client.subscriptions.update()` methods now support `paymentBehavior="activate_on_payment"`. This keeps the current subscription active while payment for the update is pending.

  ```python theme={null}
  subscription = client.subscriptions.update(
      subscriptionId="78058918-9746-4280-9b9b-1bd5115eec6e",
      planIdentifier="premium-plan",
      chargePeriod="MONTHLY",
      paymentBehavior="activate_on_payment",
  )
  ```

  The SDK validates the option and serializes it as `payment_behavior`. The new plan and features are applied only after payment succeeds; if the pending update expires, the existing subscription remains active and unchanged.
</Update>

<Update label="v2.7.2" description="July 14, 2026">
  ### New Features

  #### List, Retrieve, and Create Subscriptions

  The synchronous and asynchronous subscription clients now support the complete read and create workflow:

  * `client.subscriptions.list()` retrieves a customer's paginated subscription list with optional pagination controls.
  * `client.subscriptions.retrieve()` retrieves a subscription by its Kelviq UUID.
  * `client.subscriptions.create()` creates a subscription directly for a customer.

  ```python theme={null}
  page = client.subscriptions.list(
      customerId="cust_789",
      page=1,
      page_size=20,
  )

  subscription = client.subscriptions.retrieve(
      subscriptionId=page.results[0].id,
  )

  created = client.subscriptions.create(
      planIdentifier="plan-pro-monthly",
      chargePeriod="MONTHLY",
      customerId="cust_789",
  )
  ```

  All three methods are also available on `async_client` and return validated Pydantic models. New models cover paginated results, create payloads, product and plan details, features, files, links, and issued licenses.
</Update>

<Update label="v2.7.1" description="July 10, 2026">
  ### New Features

  #### Create One-Time Charges

  The synchronous and asynchronous clients now provide `client.charges.create()` for immediately charging a customer's saved payment method without creating a checkout session.

  ```python theme={null}
  charge = client.charges.create(
      planIdentifier="lifetime-access",
      chargePeriod="ONE_TIME",
      customerId="cust_789",
      currencyCode="USD",
      features=[{"identifier": "seats", "quantity": 5}],
      ipAddress="103.154.35.20",
  )

  print(charge.id, charge.status, charge.amount)
  ```

  **Async variant:**

  ```python theme={null}
  charge = await async_client.charges.create(
      planIdentifier="lifetime-access",
      chargePeriod="ONE_TIME",
      customerId="cust_789",
  )
  ```

  The customer must already have a usable payment method on file. `chargePeriod` is restricted to `"ONE_TIME"`; use the subscriptions API for recurring billing. Pydantic request and response models validate parameters and provide typed charge records.
</Update>

<Update label="v2.7.0" description="July 4, 2026">
  ### New Features

  #### Caching & Offline Resilience

  The SDK now includes a two-tier cache that keeps entitlement checks fast and lets your app keep working during transient network/API outages.

  * **L1 — in-memory** (on by default): entitlements are cached per-process. Fresh entries (within the TTL) are served without a network call; if the API is unreachable or returns a 5xx, the last-known value is served as a fallback.
  * **L2 — distributed** (optional): a shared store (e.g. Redis) so the cache and the offline usage queue are shared across processes / gunicorn workers / containers, and survive restarts.

  Read-through order is **L1 → L2 → API**, with write-through to both on every successful fetch.

  ```python theme={null}
  client = Kelviq.create_sync_client(
      access_token=ACCESS_TOKEN,
      enable_cache=True,   # default True
      cache_ttl=60.0,      # freshness window in seconds (default 60)
  )
  ```

  Set `enable_cache=False` to disable caching entirely. Both the sync and async clients are supported.

  #### Offline Usage Queue

  Usage/event reports that fail due to a network error are automatically **queued** and replayed on the next report call (or via `client.reporting.flush()`), so measurements are not lost during brief outages. Cached entitlement usage is updated optimistically so access checks stay consistent while offline.

  ```python theme={null}
  remaining = client.reporting.flush()          # returns count still queued
  # async: remaining = await async_client.reporting.flush()
  ```

  #### Distributed Cache — `RedisStore` / `AsyncRedisStore`

  Ready-to-use Redis-backed stores can be shared across multiple processes / gunicorn workers / containers so they use one cache and one durable usage queue. Redis is an optional extra (`pip install "kelviq-sdk[redis]"`).

  ```python theme={null}
  from kelviq_sdk import Kelviq, RedisStore

  store = RedisStore(url="redis://localhost:6379/0", prefix="kelviq:prod")
  client = Kelviq.create_sync_client(access_token=ACCESS_TOKEN, cache_store=store)
  ```

  ```python theme={null}
  # Async
  from kelviq_sdk import Kelviq, AsyncRedisStore

  store = AsyncRedisStore(url="redis://localhost:6379/0", prefix="kelviq:prod")
  async_client = Kelviq.create_async_client(access_token=ACCESS_TOKEN, cache_store=store)
  ```

  You can also bring your own backend by implementing the public `CacheStore` interface and passing it as `cache_store`. All workers/tasks that should share a cache must use the same `prefix`.
</Update>

<Update label="v2.6.0" description="June 5, 2026">
  ### New Features

  #### Checkout Session — `metadata` Parameter

  `checkout.create_session()` now accepts an optional `metadata` parameter for attaching arbitrary key-value pairs to a checkout session. The metadata is returned verbatim on the `checkout.completed` webhook payload's `metadata` field, letting you correlate a completed checkout back to your own records.

  ```python theme={null}
  response = client.checkout.create_session(
      planIdentifier="plan-pro-monthly",
      chargePeriod="MONTHLY",
      customerId="cust_789",
      successUrl="https://example.com/success",
      metadata={
          "orderId": "order_12345",
          "referralCode": "SUMMER2026",
      },
  )
  ```

  **Async variant:**

  ```python theme={null}
  response = await async_client.checkout.create_session(
      planIdentifier="plan-pro-monthly",
      chargePeriod="MONTHLY",
      customerId="cust_789",
      successUrl="https://example.com/success",
      metadata={"orderId": "order_12345"},
  )
  ```

  The keys inside `metadata` are preserved exactly as provided — they are not transformed on the way to the API, so they survive the round-trip back through webhooks unchanged.
</Update>

<Update label="v2.5.0" description="May 10, 2026">
  ### New Features

  #### Subscription Update — `trialEnd` Parameter

  `subscriptions.update()` now accepts an optional `trialEnd` parameter to control the trial period when updating a subscription.

  Pass either:

  * The literal string `"now"` to end any active trial immediately.
  * An ISO 8601 datetime string (e.g. `"2025-12-31 23:59:59"`) to set a new trial end date. The datetime must be in the future. Naive datetimes are interpreted as UTC.
  * Omit the parameter to preserve the existing trial behavior on the subscription.

  ```python theme={null}
  # End the trial now
  response = client.subscriptions.update(
      subscriptionId="78058918-9746-4280-9b9b-1bd5115eec6e",
      planIdentifier="premium-plan",
      chargePeriod="MONTHLY",
      trialEnd="now",
  )

  # Extend the trial to a specific future date
  response = client.subscriptions.update(
      subscriptionId="78058918-9746-4280-9b9b-1bd5115eec6e",
      planIdentifier="premium-plan",
      chargePeriod="MONTHLY",
      trialEnd="2025-12-31 23:59:59",
  )
  ```

  **Async variant:**

  ```python theme={null}
  response = await async_client.subscriptions.update(
      subscriptionId="78058918-9746-4280-9b9b-1bd5115eec6e",
      planIdentifier="premium-plan",
      chargePeriod="MONTHLY",
      trialEnd="2025-12-31 23:59:59",
  )
  ```

  The SDK validates `trialEnd` client-side — invalid datetime strings or past datetimes raise `InvalidRequestError` before the request is sent.
</Update>

<Update label="v2.4.0" description="May 4, 2026">
  ### New Features

  #### Checkout Session — New Optional Parameters

  Three new optional parameters on `checkout.create_session()` give you more control over the hosted checkout page.

  **`discounts_enabled`** — controls whether the coupon/discount code field is shown. Defaults to `True` on the server.

  **`lock_email`** — when `True`, the email field is pre-filled and locked so the customer cannot change it. Defaults to `False`.

  **`default_billing_country`** — ISO 3166-1 alpha-2 country code (e.g. `"US"`, `"GB"`) used to pre-fill the billing address country field.

  ```python theme={null}
  response = client.checkout.create_session(
      planIdentifier="plan-pro-monthly",
      chargePeriod="MONTHLY",
      customerId="cust_789",
      successUrl="https://example.com/success",
      discountsEnabled=False,
      lockEmail=True,
      defaultBillingCountry="US",
  )
  ```

  **Async variant:**

  ```python theme={null}
  response = await async_client.checkout.create_session(
      planIdentifier="plan-pro-monthly",
      chargePeriod="MONTHLY",
      customerId="cust_789",
      successUrl="https://example.com/success",
      discountsEnabled=False,
      lockEmail=True,
      defaultBillingCountry="US",
  )
  ```
</Update>

<Update label="v2.3.0" description="April 25, 2026">
  ### New Features

  #### Webhook Verification

  A new `validate_event` helper lets you securely verify incoming webhook requests from Kelviq in one step. It validates the HMAC-SHA256 signature using the three headers Kelviq attaches to every webhook delivery, then returns the parsed event dictionary.

  ```python theme={null}
  from flask import Flask, request
  from kelviq_sdk import validate_event, WebhookVerificationError

  app = Flask(__name__)

  @app.route('/webhook', methods=['POST'])
  def webhook():
      try:
          event = validate_event(
              payload=request.data,
              headers=request.headers,
              secret='<YOUR_WEBHOOK_SECRET>',
          )

          # Process the event
          print('Event type:', event.get('type'))

          return "", 202
      except WebhookVerificationError as e:
          return "", 403
  ```

  **`validate_event(payload, headers, secret)`**

  * `payload` — Raw request body as `bytes` or `str`. Must be the unparsed body — do not pass a pre-parsed dictionary.
  * `headers` — Any mapping of header name to value (e.g. `request.headers` in Flask). Header lookup is case-insensitive.
  * `secret` — Your webhook signing secret (`kq_whsec_...`) from the Kelviq dashboard.

  Returns the parsed event as `Dict[str, Any]`. Raises `WebhookVerificationError` if a required header is missing, the signature format is invalid, or the signature does not match.

  #### `WebhookVerificationError`

  New exception class raised by `validate_event` when verification fails. Extends `Exception`.
</Update>

<Update label="v2.2.0" description="April 13, 2026">
  ### New Features

  #### License Management Module

  A new `client.license` module provides full lifecycle management for software licenses. All methods are available in both synchronous and asynchronous clients.

  **`license.activate(licenseKey, customerId?, instanceName?, metadata?)`**

  Activates a license key and creates a new instance. Returns a `LicenseActivateResponse` (Pydantic model) containing the `instanceId`, `activatedAt`, `expiresOn`, and the full `LicenseDetails` object.

  ```python theme={null}
  response = client.license.activate(
      licenseKey="LIC-XXXX-YYYY-ZZZZ",
      customerId="cust_789",
      instanceName="My MacBook Pro",
      metadata={"os": "macOS", "arch": "arm64"},
  )

  print(response.instanceId)
  print(response.license.activationUsage)       # e.g. 1
  print(response.license.plan.product.name)     # e.g. "Kelviq Engine"
  print(response.license.subscription.billingType)  # "SUBSCRIPTION"
  ```

  **`license.deactivate(licenseKey, instanceId)`**

  Deactivates a specific license instance. Returns a `LicenseDeactivateResponse` with `message` and `deactivatedAt`.

  ```python theme={null}
  response = client.license.deactivate(
      licenseKey="LIC-XXXX-YYYY-ZZZZ",
      instanceId="8f3e2b1a-5c6d-4e9f-8a0b-1c2d3e4f5g6h",
  )
  print(response.message)  # "License instance deactivated successfully."
  ```

  **`license.validate(licenseKey, instanceId?)`**

  Validates a license key and optionally a specific instance. Returns a `LicenseValidateResponse` with `valid`, `code`, `detail`, `metadata`, and the full `LicenseDetails`.

  ```python theme={null}
  response = client.license.validate(
      licenseKey="LIC-XXXX-YYYY-ZZZZ",
      instanceId="8f3e2b1a-5c6d-4e9f-8a0b-1c2d3e4f5g6h",
  )

  if response.valid:
      print(f"Valid — code: {response.code}")
  else:
      print(f"Invalid — {response.detail}")
  ```

  #### New Pydantic Models

  * **`LicenseDetails`** — Full license object with `id`, `licenseKey`, `activatedOn`, `expiresOn`, `activationUsage`, `activationLimit`, `enabled`, `customer`, `plan`, and `subscription`.
  * **`LicenseCustomer`** — `customerId`, `name`, `email` nested within `LicenseDetails`.
  * **`LicensePlan`** — Expanded with `description`, `version`, `isLatest`, and `product` (nested `LicensePlanProduct` model).
  * **`LicensePlanProduct`** — `id`, `identifier`, `name`, `taxCode`, `createdOn`, `modifiedOn`.
  * **`LicenseActivateResponse`**, **`LicenseDeactivateResponse`**, **`LicenseValidateResponse`** — Typed Pydantic response models for each operation.

  #### `SubscriptionData` New Fields

  Three new fields derived from the subscription's `recurrence` string are now included in `SubscriptionData` (returned within `LicenseDetails.subscription` and customer subscription summaries):

  * **`billingType`** — `"SUBSCRIPTION"` if the plan has a recurrence, `"ONE_TIME"` otherwise.
  * **`recurrenceUnit`** — Integer unit from the recurrence string (e.g. `1` from `"1 month"`), or `None`.
  * **`recurrenceType`** — Recurrence period in uppercase (e.g. `"MONTH"`), or `None`.
</Update>

<Update label="v2.1.0" description="April 2, 2026">
  ### New Features

  #### Customer Portal Module

  A new `client.portal` module lets you create pre-authenticated customer portal sessions server-side and redirect customers directly to their self-serve portal. Available on both synchronous and asynchronous clients.

  **`portal.create_session(customerId)`**

  ```python theme={null}
  session = client.portal.create_session(customerId="cust_789")

  # Redirect your customer — no login required
  print(session.customerPortalUrl)
  ```

  **Async variant:**

  ```python theme={null}
  session = await async_client.portal.create_session(customerId="cust_789")
  ```

  Returns `CreatePortalSessionResponse` (Pydantic model) with:

  * `token` (str) — Session token authenticating the portal session.
  * `email` (str) — The customer's email address.
  * `customerPortalUrl` (str) — Pre-authenticated URL to redirect the customer to.
</Update>

<Update label="v2.0.0" description="February 27, 2026">
  ### New Features

  #### Entitlements Aggregation Engine

  When a customer has multiple active subscriptions, the API can return duplicate `featureId` entries. The SDK now automatically aggregates them into a single `Entitlement` model while preserving the raw data.

  ```python theme={null}
  entitlement = client.entitlements.get_entitlement("cust_123", "email-sends")

  # Aggregated values across all subscriptions
  print(entitlement.hasAccess)     # True
  print(entitlement.usageLimit)    # 1500 (e.g., 1000 from base + 500 from top-up)
  print(entitlement.currentUsage)  # 200
  print(entitlement.remaining)     # 1300

  # Per-subscription details
  for item in entitlement.items:
      print(item.usageLimit, item.resetAt)
  ```

  **Aggregation rules:**

  | Field          | Aggregation Strategy                                      |
  | -------------- | --------------------------------------------------------- |
  | `hasAccess`    | `True` if **any** raw entry grants access                 |
  | `usageLimit`   | **Summed** across all entries (for `METER` feature types) |
  | `currentUsage` | **Summed** across all entries (for `METER` feature types) |
  | `remaining`    | Computed as `usageLimit - currentUsage`                   |
  | `hardLimit`    | `True` if **any** entry has a hard limit                  |

  > **Note:** `resetAt` is intentionally **not** aggregated at the top level because each subscription item can have a different reset date. Access individual reset dates via `entitlement.items[i].resetAt`.

  #### New `Entitlement` Pydantic Model

  A new model representing an aggregated entitlement:

  * `featureId`, `featureType`, `hasAccess`, `hardLimit`, `usageLimit`, `currentUsage`, `remaining`
  * `items: List[EntitlementDetail]` — the raw entries that were aggregated into this model

  #### `get_entitlements(customerId)` — All Entitlements as a Map

  Returns a `Dict[str, Entitlement]` keyed by `featureId`. Each value is an aggregated model with an `.items` list containing the original raw entries.

  ```python theme={null}
  entitlements = client.entitlements.get_entitlements("cust_123")

  for feature_id, entitlement in entitlements.items():
      print(f"{feature_id}: hasAccess={entitlement.hasAccess}, remaining={entitlement.remaining}")
  ```

  #### `get_raw_entitlement(customerId, featureId)` — Raw API Response for a Feature

  Returns the raw API response (`CheckEntitlementsResponse`) for a specific feature without any aggregation.

  ```python theme={null}
  raw = client.entitlements.get_raw_entitlement("cust_123", "email-sends")
  ```

  #### `get_raw_entitlements(customerId)` — Full Raw API Response

  Returns the raw API response for all entitlements without any aggregation.

  ```python theme={null}
  raw = client.entitlements.get_raw_entitlements("cust_123")
  ```

  #### Enum Helpers for IDE Autocompletion

  New `str`-based `Enum` classes:

  * **`ChargePeriod`** — `ONE_TIME`, `MONTHLY`, `YEARLY`, `WEEKLY`, `DAILY`, `THREE_MONTHS`, `SIX_MONTHS`
  * **`CancellationType`** — `IMMEDIATE`, `CURRENT_PERIOD_ENDS`, `SPECIFIC_DATE`

  ```python theme={null}
  from kelviq_sdk import ChargePeriod, CancellationType

  response = client.checkout.create_session(
      planIdentifier="pro-plan",
      chargePeriod=ChargePeriod.MONTHLY,
      successUrl="https://example.com/success"
  )
  ```

  Raw string values (e.g., `"MONTHLY"`) continue to work everywhere — fully backward-compatible.

  ***

  ### Changed

  * **`get_entitlement(customerId, featureId)`** now returns `Optional[Entitlement]` (aggregated) instead of raw `CheckEntitlementsResponse`. Use `get_raw_entitlement()` for the original response.
  * **`get_all_entitlements` removed** — Use `get_entitlements()` instead.
  * **`resetAt` removed from top-level `Entitlement`** — Access individual reset dates via the `.items` list.
</Update>

<Update label="v1.1.0" description="February 27, 2026">
  ### New Features

  #### Entitlements Aggregation Engine

  When a customer has multiple active subscriptions, the API can return duplicate `featureId` entries. The SDK now automatically aggregates them into a single `Entitlement` model while preserving the raw data.

  ```python theme={null}
  entitlement = client.entitlements.get_entitlement("cust_123", "email-sends")

  # Aggregated values across all subscriptions
  print(entitlement.has_access)     # True
  print(entitlement.usage_limit)    # 1500 (e.g., 1000 from base + 500 from top-up)
  print(entitlement.current_usage)  # 200
  print(entitlement.remaining)      # 1300

  # Per-subscription details
  for item in entitlement.items:
      print(item.usage_limit, item.reset_at)
  ```

  **Aggregation rules:**

  | Field          | Aggregation Strategy                                      |
  | -------------- | --------------------------------------------------------- |
  | `hasAccess`    | `True` if **any** raw entry grants access                 |
  | `usageLimit`   | **Summed** across all entries (for `METER` feature types) |
  | `currentUsage` | **Summed** across all entries (for `METER` feature types) |
  | `remaining`    | Computed as `usageLimit - currentUsage`                   |
  | `hardLimit`    | `True` if **any** entry has a hard limit                  |

  > **Note:** `resetAt` is intentionally **not** aggregated at the top level because each subscription item can have a different reset date. Access individual reset dates via `entitlement.items[i].resetAt`.

  #### New `Entitlement` Pydantic Model

  A new model representing an aggregated entitlement:

  * `featureId`, `featureType`, `hasAccess`, `hardLimit`, `usageLimit`, `currentUsage`, `remaining`
  * `items: List[EntitlementDetail]` — the raw entries that were aggregated into this model

  #### `get_entitlements(customerId)` — All Entitlements as a Map

  Returns a `Dict[str, Entitlement]` keyed by `featureId`. Each value is an aggregated model with an `.items` list containing the original raw entries.

  ```python theme={null}
  entitlements = client.entitlements.get_entitlements("cust_123")

  for feature_id, entitlement in entitlements.items():
      print(f"{feature_id}: hasAccess={entitlement.has_access}, remaining={entitlement.remaining}")
  ```

  #### `get_raw_entitlement(customerId, featureId)` — Raw API Response for a Feature

  Returns the raw API response (`CheckEntitlementsResponse`) for a specific feature without any aggregation. Includes the `customerId` wrapper.

  ```python theme={null}
  raw = client.entitlements.get_raw_entitlement("cust_123", "email-sends")
  # CheckEntitlementsResponse with customerId wrapper
  ```

  #### `get_raw_entitlements(customerId)` — Full Raw API Response

  Returns the raw API response for all entitlements without any aggregation.

  ```python theme={null}
  raw = client.entitlements.get_raw_entitlements("cust_123")
  # CheckEntitlementsResponse with customerId wrapper and all raw entries
  ```

  #### Enum Helpers for IDE Autocompletion

  New `str`-based `Enum` classes that improve developer experience with IDE autocompletion and prevent typos:

  * **`ChargePeriod`** — `ONE_TIME`, `MONTHLY`, `YEARLY`, `WEEKLY`, `DAILY`, `THREE_MONTHS`, `SIX_MONTHS`
  * **`CancellationType`** — `IMMEDIATE`, `CURRENT_PERIOD_ENDS`, `SPECIFIC_DATE`

  ```python theme={null}
  from kelviq_sdk import ChargePeriod, CancellationType

  # With enums — IDE autocompletion and typo prevention
  response = client.checkout.create_session(
      planIdentifier="pro-plan",
      chargePeriod=ChargePeriod.MONTHLY,
      successUrl="https://example.com/success"
  )

  # Cancel with enum
  client.subscriptions.cancel(
      subscriptionId="sub_123",
      cancellationType=CancellationType.CURRENT_PERIOD_ENDS,
  )
  ```

  Raw string values (e.g., `"MONTHLY"`) continue to work everywhere — the enums are fully backward-compatible.

  ***

  ### Changed

  * **`get_entitlement(customerId, featureId)`** now returns `Optional[Entitlement]` (aggregated) instead of raw `CheckEntitlementsResponse`. Use `get_raw_entitlement()` if you need the original response.
  * **`get_all_entitlements` removed** — Use `get_entitlements()` instead, which returns a `Dict[str, Entitlement]` keyed by `featureId`.
  * **`resetAt` removed from top-level `Entitlement`** — Access individual reset dates via the `.items` list.

  ***

  ### Fixed

  * Removed invalid JavaScript-style comments (`// Server-generated UUID`) from JSON response blocks in documentation.
  * Fixed `//` comments to `#` comments in Python code snippets.
  * Fixed trailing comma in Checkout response JSON block.
  * Fixed typo: "newly created customer" → "**updated** customer" in `customers.update` return description.
  * Removed placeholder text from "Supported Functionalities" section.
  * Updated entitlements description to explain multi-subscription aggregation and the `items` attribute.
</Update>
