Verify access tokens and handle webhooks in any Python framework.
Python SDK
The ascendkit Python package provides access token verification and webhook signature validation. It works with any Python framework — FastAPI, Flask, Django, Starlette, or plain scripts.
What this SDK does and does not do
The Python SDK covers verification, user management, and OAuth initiation. It does not render sign-in UI, issue sessions, or send magic links — those live in the JS SDK, whose server-side auth runtime requires Next.js.
| Capability | Python SDK | JS SDK (@ascendkit/nextjs) |
|---|---|---|
| Verify access tokens | Yes — AccessTokenVerifier | Yes |
| Verify webhook signatures | Yes — verify_webhook_signature | Yes |
| Server-side analytics | Yes — Analytics | Yes |
| Read and update users | Yes — AuthClient | Yes |
| Start an OAuth redirect flow | Yes — build_authorize_url | Yes |
| Render sign-in UI | No | Yes — drop-in components |
| Credentials and magic link sign-in | No | Yes |
| Issue and manage sessions | No | Yes |
So a Python-only application can authenticate users through OAuth: build the authorize URL, let AscendKit handle the provider round-trip, and verify the resulting token. What you do not get is a rendered sign-in form or password and magic-link flows — for those you need the JS SDK.
How the pieces fit together
Browser ──► Next.js app ──► AscendKit
(@ascendkit/nextjs) issues access token
│
│ Authorization: Bearer <token>
▼
Python backend
(ascendkit) ──► verifier.verify(token) ──► claims
Verification is local and offline after the first call. The SDK fetches AscendKit's JWKS once, caches the keys in memory for an hour, and validates the RS256 signature itself. There is no network round-trip to AscendKit per request, so token checks stay fast and survive brief AscendKit outages.
Install
pip install ascendkit
Requires Python 3.10+. Dependencies: httpx, pydantic, pyjwt[crypto].
Access token verification
Verify RS256 access tokens issued by AscendKit using JWKS. Keys are cached in memory for 1 hour.
from ascendkit import AccessTokenVerifier, AuthError
verifier = AccessTokenVerifier()
# Sync
claims = verifier.verify(token)
print(claims["sub"]) # usr_...
print(claims["email"])
# Async
claims = await verifier.verify_async(token)
The constructor reads ASCENDKIT_ENV_KEY from your environment. If the variable is missing, it raises an error at initialization so you catch configuration issues immediately.
Raises AuthError with appropriate status codes:
401— expired or invalid token503— JWKS endpoint unreachable
The distinction matters when you decide how to respond. A 401 is the client's problem and they should re-authenticate. A 503 is yours — AscendKit's JWKS endpoint was unreachable and the key cache had expired, so returning 401 would incorrectly sign out every valid user. Map them to different responses rather than collapsing both into "unauthorized".
Which claims are available
verify() returns the decoded claims dictionary. The two you will use constantly:
| Claim | Contains | Example |
|---|---|---|
sub | The prefixed user ID | usr_abc123 |
email | The user's verified email | [email protected] |
Use sub as the foreign key in your own tables. It is stable, it is the same identifier that email, journeys, and surveys address, and it is safe to expose in APIs — unlike an internal database ID.
Framework integration
from fastapi import FastAPI, Depends, Header, HTTPException
from ascendkit import AccessTokenVerifier, AuthError
app = FastAPI()
verifier = AccessTokenVerifier()
async def get_current_user(authorization: str = Header()) -> dict:
token = authorization.removeprefix("Bearer ").strip()
try:
return await verifier.verify_async(token)
except AuthError as e:
raise HTTPException(status_code=e.status_code, detail=str(e))
@app.get("/api/profile")
async def profile(user: dict = Depends(get_current_user)):
return {"email": user["email"], "id": user["sub"]}
from flask import Flask, request
from ascendkit import AccessTokenVerifier
app = Flask(__name__)
verifier = AccessTokenVerifier()
@app.route("/api/profile")
def profile():
token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
claims = verifier.verify(token) # sync
return {"email": claims["email"]}
# middleware.py
from ascendkit import AccessTokenVerifier, AuthError
from django.http import JsonResponse
verifier = AccessTokenVerifier()
class AscendKitAuthMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if auth_header.startswith("Bearer "):
token = auth_header.removeprefix("Bearer ").strip()
try:
request.ascendkit_user = verifier.verify(token)
except AuthError as e:
return JsonResponse({"error": str(e)}, status=e.status_code)
else:
request.ascendkit_user = None
return self.get_response(request)
# views.py
from django.http import JsonResponse
def profile(request):
if not request.ascendkit_user:
return JsonResponse({"error": "Not authenticated"}, status=401)
return JsonResponse({"email": request.ascendkit_user["email"]})
Webhook verification
Verify webhook signatures using HMAC-SHA256. Includes timestamp validation (5-minute tolerance) to prevent replay attacks.
from fastapi import Request, Response
from ascendkit import verify_webhook_signature
import os
WEBHOOK_SECRET = os.environ["ASCENDKIT_WEBHOOK_SECRET"]
@app.post("/webhooks/ascendkit")
async def handle_webhook(request: Request) -> Response:
body = await request.body()
signature = request.headers.get("x-ascendkit-signature", "")
if not verify_webhook_signature(
secret=WEBHOOK_SECRET,
signature_header=signature,
payload=body.decode(),
):
return Response(status_code=401, content="Invalid signature")
event = await request.json()
# Handle event by type: user.created, user.approved, etc.
return Response(status_code=200, content="OK")
from flask import request
from ascendkit import verify_webhook_signature
import os
WEBHOOK_SECRET = os.environ["ASCENDKIT_WEBHOOK_SECRET"]
@app.route("/webhooks/ascendkit", methods=["POST"])
def webhook():
if not verify_webhook_signature(
secret=WEBHOOK_SECRET,
signature_header=request.headers.get("x-ascendkit-signature", ""),
payload=request.get_data(as_text=True),
):
return "Invalid signature", 401
event = request.get_json()
return "OK", 200
Server-side analytics
Track events from your backend with trusted identity (secret key auth):
from ascendkit import Analytics
analytics = Analytics()
analytics.track("usr_456", "checkout.completed", {"total": 99.99})
The constructor reads ASCENDKIT_SECRET_KEY from your environment. If missing, it raises an error at initialization.
Events batch in memory and flush every 30 seconds or when the batch fills (10 events). Call analytics.shutdown() for graceful cleanup.
Troubleshooting
AccessTokenVerifier() raises at import time
Deliberate. The constructor reads ASCENDKIT_ENV_KEY from the environment and raises immediately if it is missing, so a misconfigured deployment fails at startup rather than on the first authenticated request.
Set ASCENDKIT_ENV_KEY before the module is imported. In containerized deployments this usually means the environment variable is missing from the runtime config even though it exists in your build config.
Tokens verify locally but fail with 401 in production
Almost always a key mismatch: the token was issued against one environment and is being verified against another. Development and production have different keys.
Confirm the ASCENDKIT_ENV_KEY on the backend belongs to the same environment your frontend authenticates against.
Intermittent 503s under load
The JWKS cache expired and several requests tried to refresh it at once while the endpoint was slow.
Keys cache for one hour, so this is rare. If it recurs, treat 503 as retryable rather than as an auth failure — do not sign the user out.
Webhook signature verification always fails
Nearly always the payload has been re-serialized before verification. The signature is computed over the exact bytes AscendKit sent, so parsing JSON and re-encoding it produces a different string.
Pass the raw request body — await request.body() in FastAPI, request.get_data(as_text=True) in Flask — never request.json() re-dumped. If the raw body is correct and it still fails, check that server clocks are accurate: verification enforces a five-minute timestamp tolerance to prevent replay.
Analytics events never appear
Events batch in memory and flush every 30 seconds or at 10 events. A short-lived script that exits before a flush loses them.
Call analytics.shutdown() before exit. Note also that track() requires a usr_ ID — there is no anonymous event path, so analytics cannot be used to record unauthenticated traffic.
Related
- Integration — the Next.js side that issues the tokens this SDK verifies
- Webhooks — configuring the endpoints and events you verify here
- Analytics — what the event data looks like once it lands
- Installation — environment variables and their server/client split