Skip to main content

itaja

Sell subscriptions, credits and licence keys from your Python server. Declare what you sell in code, push it to Itaja, mount one webhook route (Django, FastAPI, Starlette, Flask), react to typed events.

pip install itaja          # uv add itaja · poetry add itaja

Requires Python 3.10+.

Server only. This package holds your secret API key. Never ship it to a browser or a mobile app.

Quick start

0. Configure

Put your keys in .env (never commit it) and pass them to create_itaja:

# .env
ITAJA_API_KEY=itjs_…              # sandbox key; an itjp_… key works on the live catalogue
ITAJA_WEBHOOK_SECRET=whsec_…      # signing secret of your webhook endpoint (dashboard)
ITAJA_API_URL=https://api.getitaja.com/api/v1   # optional
import os
from itaja import create_itaja

itaja = create_itaja(
    api_key=os.environ["ITAJA_API_KEY"],
    webhook_secret=os.environ["ITAJA_WEBHOOK_SECRET"],
    base_url=os.environ.get("ITAJA_API_URL"),
    catalog=[...],
)

Each argument falls back to its variable when you leave it out. Pass other values to use another account, a tenant's key or a staging API. The itaja command loads .env and .env.local itself: nothing to export in your shell. In your app, load .env the way you already do (django-environ, pydantic-settings, python-dotenv…).

1. Declare what you sell

A free plan and a paid plan, with a quota that depends on the plan.

# itaja_config.py
import os
from itaja import create_itaja, feature, group, plan

# What the customer can do
messages = feature("messages", "metered")
export = feature("export", "boolean")

# One customer holds one plan of a group
app = group("app", "My App")

free = plan(
    "free", app,
    default=True,  # everyone starts here, no payment
    includes=[messages(limit=100, reset="month")],
)

pro = plan(
    "pro", app,
    price={"amount": 5000, "currency": "XOF", "interval": "month"},
    trial_days=7,
    includes=[messages(limit=2000, reset="month"), export()],
)

itaja = create_itaja(
    api_key=os.environ.get("ITAJA_API_KEY"),
    webhook_secret=os.environ.get("ITAJA_WEBHOOK_SECRET"),
    catalog=[messages, export, free, pro],
)

Your ids ("pro", "messages") are yours. Amounts are in major units (5000 XOF, 9.99 EUR), as in the API. Mistakes the API would refuse — an undeclared feature, two default plans, a paid plan without a price, an invalid id — raise ItajaConfigError as soon as the module is imported.

2. Push it

itaja push                  # shows the diff, asks, applies

push uses the api_key of the client in itaja_config.py. Run it again: nothing changes. Change a price and a new version of the plan is created; running subscriptions keep theirs. Whatever you remove is archived, never deleted, and products made in the dashboard are never touched.

Command Does
itaja status lists what differs, exits 1 if anything does
itaja push --yes applies without asking (CI, deploy pipeline)
itaja catalog prints the JSON your code declares
itaja --config myapp.billing:itaja push your client lives elsewhere

The key's prefix picks the mode: itjs_ is sandbox, itjp_ is live.

3. Mount the webhook

One route receives Itaja's events. It checks the signature on the raw body, ignores a delivery it already handled, ignores events of the other mode, and answers 500 when your handler raises so Itaja delivers again. It checks deliveries with webhook_secret.

Django (pip install "itaja[django]")

# urls.py
from django.urls import path
from itaja.integrations.django import webhook_view
from itaja_config import itaja

urlpatterns = [path("itaja/webhook", webhook_view(itaja))]  # CSRF-exempt, POST only

FastAPI (pip install "itaja[fastapi]")

from fastapi import FastAPI
from itaja.integrations.starlette import webhook_router
from itaja_config import itaja

app = FastAPI()
app.include_router(webhook_router(itaja))  # POST /itaja/webhook

Starlette: Route("/itaja/webhook", webhook_endpoint(itaja), methods=["POST"]) from itaja.integrations.starlette.

Flask (pip install "itaja[flask]")

from flask import Flask
from itaja.integrations.flask import webhook_blueprint
from itaja_config import itaja

app = Flask(__name__)
app.register_blueprint(webhook_blueprint(itaja))  # POST /itaja/webhook

Anything else: itaja.webhooks.process(raw_body, headers) (or await itaja.webhooks.process_async(...)) returns the status and body to answer.

4. React to events

@itaja.on("checkout.paid")
def fulfil(event):
    Order.fulfil(event.data["checkoutId"])

@itaja.on("subscription.canceled")
async def downgrade(event):        # async handlers work too
    await accounts.downgrade(event.data["customerId"])

Or pass them at creation: create_itaja(..., on={"checkout.paid": fulfil}). event.data holds the fields Itaja documents for that type. The "*" handler receives every delivery, including types this version does not know yet.

Test a handler locally with a signed sample:

itaja trigger checkout.paid --url http://localhost:8000/itaja/webhook   # signs with ITAJA_WEBHOOK_SECRET from .env

What you can sell

Mode Declare with Good for
Subscriptions feature · group · plan SaaS, memberships
Credits credits · credit_pack usage, AI tokens, coins
Licence keys license desktop or mobile software
One-off payments itaja.checkout.create a physical or digital order

Each one can live in the same catalog list.

Subscriptions

The example above is the whole declaration. Around it:

team = plan(
    "team", app,
    price={"amount": 15000, "currency": "XOF", "interval": "month"},
    includes=[messages(limit=20000, reset="month"), export()],
)
  • interval: week · month · quarter · year. reset on a quota: day · week · month · year.
  • A plan without a price must be default=True; a group has at most one default.
  • trial_days gives a free period before the first charge.
itaja.subscriptions.list(customerId=customer_id)
itaja.subscriptions.cancel(subscription_id)  # at period end

Events: invoice.paid · invoice.payment_failed · subscription.past_due · subscription.canceled.

Coming with the API: itaja.subscribe() (new, upgrade, downgrade, cancel-to-free in one call), check() and report() for entitlements exist and raise NOT_YET_AVAILABLE until the API serves them (itaja-services #465, #466).

Credits

A currency your app spends as the customer uses it, sold in packs.

from itaja import credit_pack, credits

tokens = credits("tokens", "AI tokens", coin_name="Tokens")

small = credit_pack("small", tokens, 100, {"amount": 1000, "currency": "XOF"})
large = credit_pack("large", tokens, 1000, {"amount": 8000, "currency": "XOF"})
checkout = itaja.credits.buy(pack="small", customer_id=customer_id, home_url="https://shop.example/thanks", product="tokens")
# redirect the buyer to checkout["checkoutUrl"]

itaja.credits.consume(customer_id=customer_id, amount=3, product="tokens")
itaja.credits.balance(customer_id=customer_id, product="tokens")

Events: credits.granted · credits.consumed · credits.expired · credits.low · credits.clawed_back.

Licence keys

Software that unlocks on a number of devices.

from itaja import license

desktop = license(
    "desktop", "Desktop app",
    price={"amount": 25000, "currency": "XOF"},
    devices=3,             # None = unlimited
    duration="days-365",   # "lifetime" · "days-N" · "from-first-activation-days-N"
)
itaja.licenses.issue(product="desktop", customer_id=customer_id)
itaja.licenses.revoke(key_id, product="desktop", reason="chargeback")

Events: license.issued · activated · deactivated · revoked · expired · rotated · suspended · resumed · updated.

One-off payments

Nothing to declare: create a checkout for an amount, send the customer to checkoutUrl, and fulfil on checkout.paid.

checkout = itaja.checkout.create(
    homeUrl="https://shop.example/thanks",
    currency="XOF",
    lineItems=[{"name": "Sandals", "quantity": 1, "unitPrice": 5000}],
)

Payment links are a dashboard feature: no builder or endpoint here creates one.

Also in the SDK

itaja.customers.get("user_42")   # your own customer id works too
itaja.request("GET", "/me")      # any endpoint the helpers do not cover

A product made in the dashboard is named by its Itaja id: product_id="prd_…".

Verify a delivery yourself, without the route:

from itaja import construct_event

event = construct_event(raw_body, headers, secret=os.environ["ITAJA_WEBHOOK_SECRET"])

Behind several processes or servers, share the processed-delivery store (it is in memory by default) with any object that has has(event_id) and add(event_id):

class RedisDedupe:
    def has(self, event_id): return bool(redis.exists(f"itaja:{event_id}"))
    def add(self, event_id): redis.set(f"itaja:{event_id}", 1, ex=86400)

itaja = create_itaja(..., dedupe=RedisDedupe())

Errors and retries

Every failure is an ItajaError with a stable code, the HTTP status and the API's details: ItajaValidationError (400/422), ItajaAuthenticationError (401), ItajaPermissionError (403), ItajaNotFoundError (404), ItajaConflictError (409), ItajaRateLimitError (429), ItajaServerError (5xx), ItajaConnectionError, ItajaSignatureError, ItajaConfigError. No error ever contains your key.

Every write carries an Idempotency-Key, one per call and reused on its retries, so a retried write never runs twice. Network errors, 5xx, 429 and 409 IDEMPOTENCY_KEY_IN_PROGRESS are retried twice, honouring Retry-After. Pass idempotency_key= (an order id, say) to make your own retries safe too.

Configuration

Every argument of create_itaja, and the variable it falls back to:

Argument Environment Default
api_key ITAJA_API_KEY —
webhook_secret ITAJA_WEBHOOK_SECRET — (only the webhook route needs it)
base_url ITAJA_API_URL https://api.getitaja.com/api/v1
timeout / max_retries — 30 / 2
dedupe — in memory, 24 h

There is no environment argument: the key's prefix decides sandbox or live.

Development

uv sync
uv run pytest
uv run mypy
uv run ruff check

spec/catalog.schema.json and spec/signature-vectors.json (repository root) are shared with every Itaja SDK: this package builds, field for field, the catalogue the TypeScript SDK builds (spec/conformance/reference-catalog.json), and accepts every webhook the backend's own signer produced.

License

MIT

Metadata

Release files for itaja 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for itaja 0.1.0
File Size Uploaded
itaja-0.1.0.tar.gz 25.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for itaja 0.1.0
File Interpreter ABI Platform
itaja-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 55.8 kB

Release files / itaja-0.1.0.tar.gz

Download URL itaja-0.1.0.tar.gz
Size 25.2 kB
Tags Source
SHA-256 checksum
How to use checksums
b4dabeab3b9fd880a26df481e952d63c91d1a7325bc8582384f4fee1a5e10530
BLAKE2b-256 checksum
How to use checksums
306a97be6b6aa319fa870f1855d368e5678cdf08592872c0f044f6d92d320660
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / itaja-0.1.0-py3-none-any.whl

Download URL itaja-0.1.0-py3-none-any.whl
Size 30.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3f2909abd055ff49d65d6fd3b2083c4e0e009f46ecfc960765235d78badc2f2f
BLAKE2b-256 checksum
How to use checksums
2919fbe05074449fb6cd7bd74707b1e082c9a88a40b31bf9536c905fdc96a9d0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page