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:

ModeHow you get itWhat it can do
As your backendProfileClient(oauth)Any user in your project. The scopes come from What can this backend do? in the Admin Console.
As the signed-in userprofiles.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:

  1. Set Client authentication to Enhanced (private_key_jwt) and download the private key. See Confidential Clients.
  2. Turn on Profile access (hx_profile). Only a project admin can change it.
  3. Under What can this backend do?, pick View only or View, edit & delete.
  4. To act for signed-in users, turn on Let users manage their own profile.
  5. 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 argumentDefaultDescription
base_url{issuer}/profileProfile API base URL. Must be https (plain http only on localhost).
scopesall the client's scopesAsk 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.
timeout10.0Timeout 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_retries2Retries 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)
MethodReturnsScope
get_user(user_id)Profileusers:read
list_users(*, email=, status=, cursor=, limit=)Page[Profile]users:read
iter_users(*, email=, status=, limit=)Iterator[Profile]users:read
create_user(user)Profileusers:write
update_user(user_id, patch)Profileusers:write
replace_user(user_id, user)Profileusers:write
delete_user(user_id)Noneusers:delete
get_consents(user_id, *, cursor=, limit=)ConsentsPageconsent:read
set_consent(user_id, purpose, consent)Consentconsent: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"})
MethodReturns
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.

TypeFields
Profileuser_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
Consentconsent_id, purpose, status, lawful_basis, notice_version, source, created_at
AuditEventevent_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
AttributeDescription
statusHTTP status; 0 if no response arrived (network error or timeout).
title, detail, type, instanceThe API's error fields.
request_idInclude this when you contact support.
missingOn 403: the scopes the client is missing. None when the API leaves it out.
fieldsOn 422, when the API names them: the fields that were rejected. None when the API leaves it out.
retry_afterSeconds to wait, when the API sent one.
raw_bodyThe 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_user is 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 within timeout. 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, PUT and DELETE are retried up to max_retries times on network errors and on 408, 429, 502, 503 and 504. The client waits as long as the Retry-After header asks (up to 30 seconds), otherwise a short random backoff. It honours only the delta-seconds form of Retry-After; it ignores an HTTP date, so it uses the short backoff and retry_after is None.
  • POST and PATCH are 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_user and 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_retries and use the short backoff only.
  • A retried delete_user that 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

Last updated on