Skip to main content

🐍 Qeet ID for Python

The server-side Python SDK for Qeet ID: manage an organisation's people and access, verify webhooks and access tokens, and run OAuth flows from Python.

Python Typed Sync + async Runtime dependencies Tests OpenID Connect

🌐 Qeet ID · 📖 Docs · 🚀 Quick start · 🧭 Resources · 📝 Changelog · 🟢 Node SDK · 🐹 Go SDK

📦 What's inside

🧭 Typed clients QeetID and AsyncQeetID, the same 38 resource namespaces and 269 API calls, each checked against the server's OpenAPI documents. Every request and response is a typed dict
🚨 Typed errors A QeetIDError hierarchy: one class per HTTP status, SessionRevokedError, OAuthError, NetworkError, RequestTimeoutError. Each carries the stable code, the request_id and retry_after
🔁 Safe retries Exponential backoff with jitter and Retry-After, only for requests that can't act twice. An Idempotency-Key sent automatically on the nine creates the API makes idempotent
📄 Pagination for (or async for) walks every item across pages; whole lists come as ListResult with truncated
🔐 Two credentials An API key, or a service principal's client ID and secret, exchanged for a cached, renewed token
🪝 Webhooks Timestamped HMAC-SHA256 verification in constant time, two secrets during a rotation, a five-minute replay window, typed events
🔑 Access tokens Local ES256 verification against the issuer's keys, with a required issuer and audience, a JWKS cache that rides out outages
🤝 OAuth Client credentials (with expires_in), code exchange, refresh, token exchange, device and CIBA polling, introspection, revocation, a token guard
🪶 Lean Three runtime dependencies: httpx2, anyio and cryptography. No pydantic, no JWT library

🔗 How it fits

flowchart LR
    subgraph you["🖥️ Your server: Django · FastAPI · Flask · workers"]
        SDK["qeet-id"]
    end
    SDK -->|"ApiKey or Bearer · retries · idempotency keys"| API["☁️ api.id.qeet.in"]
    API -->|"signed webhooks"| SDK
    App["📱 Your apps"] -->|"access tokens"| SDK
    SDK -.->|"JWKS, cached"| API
SDK Language Role
qeet-id Python This package: the API, webhooks, tokens and OAuth from Python servers
@qeet-id/node Node.js / TypeScript The same for Node.js
qeet-id-go Go The same for Go

📥 Install

Python 3.11 or later; tested on 3.11, 3.12, 3.13, 3.14 and 3.15.

pip install qeet-id
# or: uv add qeet-id · poetry add qeet-id

🚀 Quick start

Create an API key in the Qeet ID console and keep it in a secret store.

import os

from qeet_id import QeetID

client = QeetID(api_key=os.environ["QEET_ID_API_KEY"])
tenant_id = os.environ["QEET_ID_TENANT_ID"]

user = client.users.create(
    tenant_id=tenant_id,
    email="meera@brightleaffoods.in",
    display_name="Meera Iyer",
)
client.roles.assign(tenant_id, user["id"], finance_role_id)

allowed = client.permissions.check(user_id=user["id"], tenant_id=tenant_id, permission="invoice.approve")

The same with asyncio (or trio):

from qeet_id import AsyncQeetID

async with AsyncQeetID(api_key=os.environ["QEET_ID_API_KEY"]) as client:
    user = await client.users.get(user_id)
    async for member in client.users.list(status="active"):
        print(member["email"])

Create one client per process and share it: it is safe across threads (sync) or tasks (async), and reuses connections and cached tokens. Use it as a context manager, or call close(), to release its connections.

Machine credentials

A service principal's client ID and secret work in place of an API key. The client gets an access token with the client-credentials grant, caches it, and renews it a minute before it expires; concurrent calls share one fetch.

client = QeetID(
    client_id=os.environ["QEET_ID_CLIENT_ID"],
    client_secret=os.environ["QEET_ID_CLIENT_SECRET"],
)

⚙️ Configuration

client = QeetID(
    api_key=os.environ["QEET_ID_API_KEY"],
    timeout=30.0,
    max_retries=2,
    app_info={"name": "brightleaf-onboarding", "version": "1.4.0"},
    default_headers={"X-Team": "people-ops"},
)
Option Default
api_key none Sent as Authorization: ApiKey <key>
client_id, client_secret none A service principal's credentials, instead of api_key
base_url https://api.id.qeet.in Must be https; plain http only for localhost, *.localhost and loopback addresses
timeout 30.0 Seconds each attempt may wait to connect, send or read; or an httpx2.Timeout
max_retries 2 Retries after a transient failure, for retry-safe requests only; 0 turns them off
default_headers none Sent on every request; can't replace Authorization, Accept, Content-Type, User-Agent or Idempotency-Key
app_info none {"name", "version", "url"}, put in front of the User-Agent
http_client a new one An httpx2.Client (or httpx2.AsyncClient) of your own, for a proxy, custom TLS or tests. Never closed by the SDK

Every method takes request_options last, overriding the client for that call:

client.users.get(user_id, request_options={"timeout": 5.0, "max_retries": 0, "headers": {"traceparent": trace}})

🧭 Resources

Identifiers come first and positionally; request bodies and filters are keyword arguments with the API's field names. Responses are typed dicts with the API's field names too (user["display_name"], user["created_at"]).

Area Namespaces
👥 Identity tenants (the organisation) · domains · users · groups · invitations · branding
🛡️ Access roles · permissions · relationships (ReBAC tuples) · decisions (OpenID AuthZEN) · auth_policies · security_policies · ip_rules · threat_detection (bots, risk, anomalies)
🌐 Federation oauth · applications (OAuth/OpenID clients and RFC 8707 resources) · service_principals · saml_connections · saml_service_providers · ldap_connections · scim · social_providers · admin_portal_links
🛠️ Developer api_keys · agents · verifiable_credentials · auth_hooks · webhooks · secrets · token_vault
📊 Operations audit_logs · analytics · billing · gdpr · email_templates · retention · log_sinks · rate_limits
page = client.audit_logs.list(tenant_id, action="user.deleted", from_="2026-11-01T00:00:00Z")
client.webhooks.update(tenant_id, webhook_id, events=["user.created", "user.deleted"])
rotated = client.webhooks.rotate_secret(tenant_id, webhook_id)
secrets = client.secrets.list(tenant_id)  # ListResult: iterate it; secrets.truncated says if the API capped it

A field named after a Python keyword takes a trailing underscore and is sent without it: from_ is from.

The SDK covers the calls an API key may make. A person's own account (their sessions, factors, passkeys, email changes) is theirs to manage through the hosted pages. What isn't wrapped yet is listed in docs/FUTURE.md.

🚨 Errors

Every failure is raised. Catch the class, or branch on code, never on the message.

Class When Worth reading
BadRequestError 400 code, detail
AuthenticationError 401 code
↳ SessionRevokedError 401 auth.session_revoked sign in again; never retried
PaymentRequiredError 402 code: the plan doesn't include it, or a limit is reached
PermissionDeniedError 403 code
NotFoundError 404
ConflictError 409 code, such as idempotency.outcome_unknown
ValidationError 422 fields: each field's code and message
RateLimitError 429 retry_after
InternalServerError 5xx request_id
↳ ServiceUnavailableError 503 retry_after
OAuthError an OAuth endpoint refused code is the RFC 6749 error, such as invalid_grant
NetworkError · RequestTimeoutError no response, or none in time __cause__
WebhookVerificationError a delivery failed verification code, such as signature_mismatch
TokenVerificationError an access token failed verification code, such as token_expired

All of them extend QeetIDError; the API ones extend APIError, which also has status, request_id (quote it to support), detail, metadata, retryable, retry_after and headers.

from qeet_id import ConflictError, RateLimitError, ValidationError

try:
    client.users.create(tenant_id=tenant_id, email=email)
except ValidationError as error:
    for field in error.fields:
        print(field.field, field.code, field.message)
except ConflictError as error:
    if error.code != "user.domain_managed_elsewhere":
        raise
except RateLimitError as error:
    print(f"retry in {error.retry_after} s, request {error.request_id}")

A mistake in your own arguments (an empty identifier, a malformed idempotency key, a reserved header) is a ValueError or TypeError, raised before anything is sent.

🔁 Retries & timeouts

✅ Retried GET and DELETE; POSTs that change nothing (permission and relationship checks, AuthZEN evaluations, previews, introspection, revocation, the client-credentials grant); requests carrying an Idempotency-Key
⚡ After no response, a timeout, 408, 429, 500, 502, 503, 504, 409 idempotency.in_progress
⛔ Never PUT, PATCH and other POSTs; single-use OAuth grants; directory sign-in (counts toward the lockout); any 401, so never auth.session_revoked, whatever the server's retryable hint says
⏱️ Waits the server's Retry-After up to 60 s (a longer one is raised as is), otherwise 0.5 s doubling to 8 s with jitter

Each attempt has its own timeout. To bound a whole call, retries included, wrap it: anyio.fail_after(10) on the async client.

🔂 Idempotency

users.create, groups.create, invitations.create, service_principals.create, api_keys.create, agents.create, webhooks.create, billing.checkout and billing.pay_invoice always send an Idempotency-Key: one generated per call, so the SDK's own retries never create twice. Pass your own to make retries across calls, deployments or processes safe too:

client.users.create(
    tenant_id=tenant_id,
    email="meera@brightleaffoods.in",
    idempotency_key=f"onboarding-{employee.id}",  # 1 to 255 printable ASCII characters
)

The API keeps the answer for 24 hours. The same key with another body is refused (422 idempotency.key_reused); 409 idempotency.outcome_unknown means the first request was interrupted: check whether it took effect before trying again with a new key.

📄 Pagination

Paged lists (users, audit log, consent grants, SCIM users and groups, anomalies, decisions, webhook deliveries, relationship objects) iterate across every page:

for user in client.users.list(status="active", limit=100):
    print(user["email"])

page = client.users.list(limit=50)  # one page
page.data, page.next_cursor
if page.has_next_page():
    page = page.get_next_page()
for p in page.iter_pages():
    print(len(p.data))

On the async client, list() is awaitable for one page and async for walks them all:

page = await client.users.list(limit=50)
async for user in client.users.list(status="active"):
    ...

Other lists come whole as a ListResult: iterate it, index it, take its len(); truncated is True when the API capped the list.

🪝 Webhooks

Each delivery is a signed POST whose body is an event:

{"id": "…", "type": "user.created", "tenant_id": "…", "occurred_at": "2026-11-05T09:30:00Z", "data": {…}}

construct_webhook_event checks X-Qeet-Signature (sha256= and the HMAC-SHA256 of X-Qeet-Timestamp, . and the raw body) in constant time, refuses a delivery signed more than five minutes from now, and returns the event. It needs no client and no API key. Always pass the raw body: a parsed and re-serialised one won't match.

Django

from django.conf import settings
from django.http import HttpRequest, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from qeet_id import WebhookVerificationError, construct_webhook_event


@csrf_exempt
@require_POST
def qeet_id_webhook(request: HttpRequest) -> HttpResponse:
    try:
        event = construct_webhook_event(request.body, request.headers, settings.QEET_ID_WEBHOOK_SECRET)
    except WebhookVerificationError as error:
        return HttpResponse(error.code, status=400)
    if already_handled(event["id"]):  # deliveries can repeat
        return HttpResponse(status=204)
    if event["type"] == "user.deleted":
        remove_local_data(event["data"])
    return HttpResponse(status=204)

FastAPI

from fastapi import FastAPI, HTTPException, Request, Response

from qeet_id import WebhookVerificationError, construct_webhook_event

app = FastAPI()


@app.post("/webhooks/qeet-id", status_code=204)
async def qeet_id_webhook(request: Request) -> Response:
    try:
        event = construct_webhook_event(await request.body(), request.headers, WEBHOOK_SECRET)
    except WebhookVerificationError as error:
        raise HTTPException(status_code=400, detail=error.code) from error
    await handle(event)
    return Response(status_code=204)

Flask

from flask import Flask, abort, request

from qeet_id import WebhookVerificationError, construct_webhook_event

app = Flask(__name__)


@app.post("/webhooks/qeet-id")
def qeet_id_webhook():
    try:
        event = construct_webhook_event(request.get_data(), request.headers, WEBHOOK_SECRET)
    except WebhookVerificationError as error:
        abort(400, error.code)
    handle(event)
    return "", 204
🔄 Rotating the secret webhooks.rotate_secret() returns a new secret; for 24 hours deliveries carry both signatures. Pass both meanwhile: construct_webhook_event(body, headers, [new_secret, old_secret])
🧾 Duplicates an event's id is the same in every delivery of it, retries included
🆕 Unknown events event["type"] may be newer than this SDK; ignore what you don't handle. qeet_id.webhooks.WEBHOOK_EVENT_TYPES lists the 46 this version knows
🧪 Testing sign_webhook_payload(body, secret) returns the headers Qeet ID would send
⚠️ X-Qeet-Event isn't signed: read event["type"] instead

🔑 Verifying access tokens

To accept Qeet ID access tokens in your own API, verify them locally against the issuer's signing keys:

from qeet_id import TokenVerificationError, TokenVerifier

verifier = TokenVerifier.from_discovery(audience="https://api.brightleaffoods.in")  # once, at start-up

try:
    claims = verifier.verify(bearer_token)
except TokenVerificationError as error:
    raise Unauthorized(error.code)
claims["sub"], claims.get("tenant_id"), claims.get("scope")

AsyncTokenVerifier is the same with await. Or configure one directly with TokenVerifier(issuer=..., audience=..., jwks_url=...); both issuer and audience are required.

🔏 Algorithm ES256 only; none, HMAC and RSA tokens are refused, and so is a token without a kid
✅ Claims iss and aud must match; exp, nbf and iat are checked with 30 s of leeway (leeway=)
🗝️ Keys cached for five minutes; a token naming an unknown kid refetches them at most every 30 s
🌩️ Outages keys already fetched keep verifying while the issuer can't be reached; it is retried every 30 s

A token verified offline stays valid until it expires, even if its session was revoked. Where revocation must take effect at once, introspect instead:

guard = client.oauth.create_token_guard(
    audience="https://mcp.brightleaffoods.in",
    client_id=os.environ["MCP_CLIENT_ID"],
    client_secret=os.environ["MCP_CLIENT_SECRET"],
)
claims = guard.verify(bearer_token, ["files:read"])  # AuthenticationError (401) or PermissionDeniedError (403)

🤝 OAuth

# Machine-to-machine: a service principal's token (POST /v1/oauth/token).
token = client.oauth.client_credentials_grant(client_id=sp_id, client_secret=sp_secret)
token["access_token"], token["expires_in"], token["expires_at"]

# The other grants (POST /v1/oauth/token-code). Leave client_secret out for a public client.
client.oauth.authorization_code_grant(
    client_id=app_id,
    client_secret=app_secret,
    code=code,
    redirect_uri="https://app.brightleaffoods.in/callback",
    code_verifier=verifier,
)
client.oauth.refresh_token_grant(client_id=app_id, client_secret=app_secret, refresh_token=refresh_token)
client.oauth.token_exchange_grant(
    client_id=app_id, client_secret=app_secret, subject_token=user_token, actor_token=agent_token, scope="invoices:read"
)

client.oauth.introspect(access_token, client_id=app_id, client_secret=app_secret)
client.oauth.revoke(refresh_token, client_id=app_id, client_secret=app_secret)
client.oauth.discover()

The device flow (and CIBA, with backchannel_authentication and ciba_grant) polls until the person decides:

import time

from qeet_id import OAuthError

start = client.oauth.device_authorization(client_id=tv_app_id, scope="openid profile")
show(start["user_code"], start["verification_uri"])
interval = start.get("interval", 5)
while True:
    time.sleep(interval)
    try:
        tokens = client.oauth.device_code_grant(client_id=tv_app_id, device_code=start["device_code"])
        break
    except OAuthError as error:
        if error.code == "slow_down":
            interval += 5
        elif error.code != "authorization_pending":
            raise  # access_denied or expired_token

The OAuth endpoints answer errors in the RFC 6749 shape, raised as OAuthError with code set to the error: invalid_request, invalid_client (status 401), invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope, invalid_target, and while polling authorization_pending, slow_down, access_denied and expired_token. Confidential applications authenticate with HTTP Basic (client_secret_basic); single-use grants are never retried.

🗂️ Directory sign-in

ldap_connections.authenticate signs a person in with their directory password. The result is tokens, or a second-factor challenge:

result = client.ldap_connections.authenticate(connection_id, username=username, password=password)
if result["type"] == "mfa_challenge":
    ask_for_second_factor(result["challenge"]["mfa_token"], result["challenge"]["factors"])
else:
    start_session(result["tokens"])

📝 Logging

The SDK logs to the qeet_id logger: each attempt at DEBUG (method, path, status, duration, attempt, request ID) and each retry at INFO. Never credentials, query strings, headers or bodies. Turn it on with logging.getLogger("qeet_id").setLevel(logging.DEBUG).

🧩 Typing

The package ships py.typed and passes mypy --strict. Every request and response type is importable:

from qeet_id.types import User, UserCreateParams, WebhookSubscription
from qeet_id import AccessTokenClaims, OAuthErrorCode, WebhookEvent

Keyword arguments are checked against the API's fields, so a misspelt field or a wrong value is a type error.

🔨 Building

uv sync
make check   # Ruff, the generated sync client, mypy, tests with coverage, build, twine check, pip-audit

Every command is in CONTRIBUTING.md.

📚 Learn more

🧪 Examples Onboarding a person, an async audit report, a webhook receiver, protecting an API
🏗️ Architecture How a request travels from a method to the API and back
🧠 Design decisions One core, httpx2, namespaces, typed dicts, retries, tokens, OAuth client authentication
📐 Engineering practices The practices this SDK follows, with their sources
🗺️ What's not covered yet API areas still to wrap, and those left out on purpose
🤝 Contributing Setting up, conventions, tests
📝 Changelog What's in each version
🔒 Security Reporting a vulnerability to security@qeet.in

Made with 🧡 by Qeet Group · ⬆️ Back to top

Metadata

Release files for qeet-id 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 qeet-id 0.1.0
File Size Uploaded Provenance Actions
qeet_id-0.1.0.tar.gz 117.2 kB With provenance Details for qeet_id-0.1.0.tar.gz

Built distribution (wheel)

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

Total release size: 248.0 kB

Release files / qeet_id-0.1.0.tar.gz

Download URL qeet_id-0.1.0.tar.gz
Size 117.2 kB
Tags Source
SHA-256 checksum
How to use checksums
63980173d6dff46fd352caf6e88fac88e3ff007b72c31382f53539bc4affabdf
BLAKE2b-256 checksum
How to use checksums
932978c0d53c02cb751712937a80830b86485d470942018f2cab0cde8511cc55
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Nov 5, 2026.

Transparency log

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

Download URL qeet_id-0.1.0-py3-none-any.whl
Size 130.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e324ae9cc6982c6fc1b23b7e21abf1cc67c6760d86a0ddb9b27e134b6eff851d
BLAKE2b-256 checksum
How to use checksums
40701295eb20bfade99c3b415f3c72a8e782f0bea4b35d623a78632079a589ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Nov 5, 2026.

Transparency log

Security for qeet-id 0.1.0

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.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for qeet_id-0.1.0.tar.gz

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 Nov 5, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

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