Python Profile Service
Read and manage user profiles, consents and the activity log from your Python backend with ProfileClient
ProfileClient calls the Hawcx Profile API: the profile, consent records and
activity log Hawcx keeps for each user. It runs on your backend only, and it
works in two modes:
| Mode | How you get it | What it can do |
|---|---|---|
| As your backend | ProfileClient(oauth) | Any user in your project. The scopes come from What can this backend do? in the Admin Console. |
| As the signed-in user | profiles.for_user(id_token) | Only that user's own profile, consents and activity (/v1/me). |
Available in hawcx-oauth-client 2.2.0 and later. AsyncProfileClient
has the same methods for asyncio.
Before you start
In the Admin Console, open your project → Settings → OAuth tab, and in OIDC Clients edit the client your backend signs in with:
- Set Client authentication to Enhanced (private_key_jwt) and download the private key. See Confidential Clients.
- Turn on Profile access (hx_profile). Only a project admin can change it.
- Under What can this backend do?, pick View only or View, edit & delete.
- To act for signed-in users, turn on Let users manage their own profile.
- Save the client.
View only grants users:read, consent:read and audit:read.
View, edit & delete adds users:write, users:delete and consent:write.
Let users manage their own profile grants profile:self. When you turn
on profile access, the console grants View only by default.
Use the same client for sign-in
for_user only accepts an ID token that was issued to this same client. If
users sign in through a different client, the exchange fails with
invalid_grant.
Set up
Create the client once at startup and reuse it. It caches its access token and
refreshes it before it expires. If a refresh fails while the current token is
still valid (for any reason), the client keeps using it, so a short outage at
the token endpoint does not fail your calls. It then waits 30 seconds, or until
the token expires if that is sooner, before it tries to refresh early again. Tokens that for_user gets are not refreshed early: they are
refreshed only when they expire. Call close() on shutdown, or use it as a
context manager.
import os
from hawcx_oauth_client import ClientAssertionSigner, HawcxOAuth, ProfileClient
oauth = HawcxOAuth.from_issuer(
os.environ["HAWCX_BASE_URL"],
os.environ["HAWCX_CONFIG_ID"],
os.environ["HAWCX_CLIENT_ID"],
).with_client_assertion(
ClientAssertionSigner.ed25519_from_jwk(os.environ["HAWCX_OAUTH_PRIVATE_KEY_JWK"])
)
profiles = ProfileClient(oauth)ProfileClient raises ValueError if oauth has no client-assertion signer, and TypeError if it is a HawcxOAuthAsync (use AsyncProfileClient for that).
For asyncio, build AsyncProfileClient from a HawcxOAuthAsync. await
each call, use async for with the iter_* methods, and close it with
await profiles.aclose() or async with:
import asyncio
import os
from hawcx_oauth_client import AsyncProfileClient, ClientAssertionSigner, HawcxOAuthAsync
async def main():
oauth = (await HawcxOAuthAsync.from_issuer(
os.environ["HAWCX_BASE_URL"],
os.environ["HAWCX_CONFIG_ID"],
os.environ["HAWCX_CLIENT_ID"],
)).with_client_assertion(
ClientAssertionSigner.ed25519_from_jwk(os.environ["HAWCX_OAUTH_PRIVATE_KEY_JWK"])
)
async with AsyncProfileClient(oauth) as profiles:
user = await profiles.get_user("user-123")
async for p in profiles.iter_users(status="active"):
print(p.user_id)
asyncio.run(main())| Keyword argument | Default | Description |
|---|---|---|
base_url | {issuer}/profile | Profile API base URL. Must be https (plain http only on localhost). |
scopes | all the client's scopes | Ask for fewer scopes, e.g. ["users:read"]. Omit it to use all of them; an empty list is rejected when you build the client. |
audience | "hx-profile" | Token-exchange audience for for_user. Change it only if Hawcx support gives you a different value. |
timeout | 10.0 | Timeout in seconds for each phase of a request (connecting, sending, each read), not for the whole call. It applies to token-endpoint calls too. |
max_retries | 2 | Retries for GET, PUT and DELETE, and for failures getting the access token. See Retries. |
As your backend
user = profiles.get_user("user-123")
# None clears a field (not `status`, which can't be cleared); a key you leave out is unchanged.
profiles.update_user("user-123", {"display_name": "Ada", "locale": None})
profiles.set_consent("user-123", "marketing", {"status": "granted"})
for p in profiles.iter_users(status="active"):
print(p.user_id, p.email)| Method | Returns | Scope |
|---|---|---|
get_user(user_id) | Profile | users:read |
list_users(*, email=, status=, cursor=, limit=) | Page[Profile] | users:read |
iter_users(*, email=, status=, limit=) | Iterator[Profile] | users:read |
create_user(user) | Profile | users:write |
update_user(user_id, patch) | Profile | users:write |
replace_user(user_id, user) | Profile | users:write |
delete_user(user_id) | None | users:delete |
get_consents(user_id, *, cursor=, limit=) | ConsentsPage | consent:read |
set_consent(user_id, purpose, consent) | Consent | consent:write |
get_audit(user_id, *, cursor=, limit=) | Page[AuditEvent] | audit:read |
iter_audit(user_id, *, limit=) | Iterator[AuditEvent] | audit:read |
for_user(id_token) | UserProfileClient | — |
create_user adds a profile for a user who already exists in Hawcx: pass a
dict with their user_id, given_name and family_name. It fails with 404 if
there is no such user and 409 if they already have a profile.
replace_user replaces the whole profile: it needs the given and family name,
and clears every other optional field you leave out (status is kept). Use
the update method to change only some fields.
As the signed-in user
Pass the ID token you just received at sign-in, and make the calls straight away.
Only right after sign-in
An ID token is valid for 60 seconds by default, and the token that
for_user gets lasts no longer than the ID token. Don't store the ID token
for later. To read or change a user's profile later (for example, on an
account page), use the backend methods with their user_id (the sub claim).
Once the ID token expires, calls fail with invalid_grant.
result = oauth.exchange_code(code, code_verifier)
me = profiles.for_user(result.id_token)
mine = me.get_me()
me.update_me({"given_name": "Ada"})
me.set_my_consent("marketing", {"status": "withdrawn"})| Method | Returns |
|---|---|
get_me() | Profile |
update_me(patch) | Profile |
get_my_consents(*, cursor=, limit=) | ConsentsPage |
set_my_consent(purpose, consent) | Consent |
get_my_audit(*, cursor=, limit=) | Page[AuditEvent] |
iter_my_audit(*, limit=) | Iterator[AuditEvent] |
update_me takes only the fields a user may change about themselves: given_name, family_name, display_name, date_of_birth, address, locale and timezone. Any other field (for example status) is rejected by the API with 422.
Data shapes
Results are dataclasses with snake_case attributes, exactly as the API sends
them. Profile, Consent and AuditEvent also have raw, the full response
dict, for fields this SDK version doesn't know yet.
| Type | Fields |
|---|---|
Profile | user_id, tenant_id, email, email_verified, phone, phone_verified, given_name, family_name, display_name, date_of_birth, address, locale, timezone, marketing_opt_in, custom_attributes, status, created_at, updated_at |
| consent dict (input) | status ("granted" or "withdrawn"), and optional lawful_basis, notice_version, source |
Consent | consent_id, purpose, status, lawful_basis, notice_version, source, created_at |
AuditEvent | event_id, actor, action, resource, before_hash, after_hash, detail, created_at |
Lists return a Page with items and next_cursor. Pass next_cursor back
as cursor to get the next page; it is None on the last page. An empty string also ends paging. limit is
1–200 (default 50). Consents return a ConsentsPage with current, history
and next_cursor, where current maps each purpose to its latest consent. The
iter_* methods follow every page for you.
Errors
An ID or consent purpose that is empty, ., .., or contains /, \ or a
control character is rejected before any request with ValueError.
A failed call raises ProfileApiError, a subclass of HawcxOAuthError.
Getting the access token can also fail with TokenExchangeError (for example,
invalid_scope when the client has no profile access).
from hawcx_oauth_client import ProfileApiError
try:
profiles.get_user("user-123")
except ProfileApiError as err:
if err.status != 404:
raise
# no such user| Attribute | Description |
|---|---|
status | HTTP status; 0 if no response arrived (network error or timeout). |
title, detail, type, instance | The API's error fields. |
request_id | Include this when you contact support. |
missing | On 403: the scopes the client is missing. None when the API leaves it out. |
fields | On 422, when the API names them: the fields that were rejected. None when the API leaves it out. |
retry_after | Seconds to wait, when the API sent one. |
raw_body | The response body as text (up to 4,096 characters), for example a gateway's HTML error page. Never part of str(err). |
A call that succeeds on the wire but returns an unusable body also raises ProfileApiError, with that 2xx status:
Invalid JSON response: the body isn't JSON.Unexpected response body: the body is JSON but not the expected object, or the API answered 204 to a call that returns a value.delete_useris the exception: any 2xx with an empty body, a whitespace-only body or a body that is not a JSON object counts as success.Response body read failed: the response started but its body never arrived in full: the connection dropped, or the body did not finish withintimeout. The request reached the API, so a create or update may have happened; check before you try again.
On a 2xx, the raw body is kept only for a markup page such as HTML or XML (after any whitespace or BOM, it starts with <); otherwise it is None, because a damaged response may still hold a user's profile.
Retries
GET,PUTandDELETEare retried up tomax_retriestimes on network errors and on 408, 429, 502, 503 and 504. The client waits as long as theRetry-Afterheader asks (up to 30 seconds), otherwise a short random backoff. It honours only the delta-seconds form ofRetry-After; it ignores an HTTP date, so it uses the short backoff andretry_afterisNone.POSTandPATCHare never retried once the Profile API has accepted the token, so a create or update never runs twice.- On 401, for every method, the client gets a new token and tries once more. The API rejects a request with an invalid token before doing anything, so this can't repeat a change. If you used
for_userand getting the new token then fails because the ID token has expired, the call raises the original 401 error (ProfileApiError, status 401), not the token error. - If getting the access token fails with a network error or 408, 429, 502, 503 or 504, the call is retried whatever its method, because nothing was sent to the Profile API yet. These retries count toward
max_retriesand use the short backoff only. - A retried
delete_userthat ends in 404 succeeds if an earlier attempt may already have deleted the user: that attempt was sent but got no response (a failure to connect doesn't count), had its 2xx body cut off mid-read, or got 504. Otherwise the 404 is raised. - Redirects are never followed, so the token is never sent to another host.
Next steps
- Python API Reference — the rest of
hawcx-oauth-client. - Confidential Clients (private_key_jwt) — the key this client signs with.
Last updated on