🐍 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.
🌐 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.