Metadata-Version: 2.5
Name: bitid-sdk
Version: 0.3.3
Summary: Python SDK for Beem's bitID onboarding GraphQL API (email/phone check, OTP, user creation, bank connection, qualification), BitID-Core Proxy (crypto-wallets, identity, ledger, agents), and Sign-in (partner OAuth2 + PKCE, browser-mediated or driven directly)
Author-email: Beem <support@useline.com>
License: Proprietary
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: httpx<1.0,>=0.27
Requires-Dist: pydantic<3.0,>=2.4
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: notebook
Requires-Dist: ipykernel>=6.29; extra == 'notebook'
Requires-Dist: jupyter>=1.0; extra == 'notebook'
Requires-Dist: python-dotenv>=1.0; extra == 'notebook'
Description-Content-Type: text/markdown

# bitid-sdk

Python SDK for Beem's bitID APIs, three surfaces in one client:

- **Onboarding** — the ten operations a third-party partner's backend needs
  to sign a user up through Beem: check existing email/phone, phone + email
  OTP, create the user, update their profile, connect a bank account, and
  evaluate their line qualification.
- **Sign-in** — the partner OAuth2 + PKCE token flow: build the consent-webview
  URL, redeem the authorization code, read the consented user's identity, and
  keep the token alive. A different user population from onboarding — this is
  for a user who already has BitID/Beem identity and is granting *your* app
  access to it, not one you're creating from scratch. Also covers minting
  that same scoped token directly after your own onboarding call, no browser
  needed. See [Sign-in](#sign-in) below.
- **BitID-Core Proxy** — crypto-wallets / machine-agent-layer (identity,
  wallets, balance, ledger, biometrics, agents), proxied through the same
  `/graphql` endpoint and the same service-account key as onboarding, rather
  than a second host or credential. See
  [BitID-Core Proxy](#bitid-core-proxy) below.

> **Status:** pre-release, partner integrations only. Published to TestPyPI
> during development — see [Install](#install).

## What's covered

These ten operations, in the order a real onboarding flow calls them:

| # | Method | Backend operation | Auth |
|---|---|---|---|
| 1 | `onboarding.check_existing_email_phone` | `bitidCheckExistingEmailPhone` | service-account key |
| 2 | `onboarding.save_phone_and_send_otp` | `bitidSavePhoneAndSendOtp` | service-account key |
| 3 | `onboarding.verify_phone_otp` | `bitidPhoneOTPverification` | service-account key |
| 4 | `onboarding.create_line_user` | `bitidCreateLineUser` | service-account key |
| 5 | `onboarding.verify_email_otp` | `bitidEmailOTPverification` | service-account key |
| 6 | `onboarding.update_user` | `updateUser` | user session |
| 7 | `onboarding.initiate_bank_connection` | `initiateBankConnection` | user session |
| 8 | `onboarding.process_bank_connection` | `processBankConnection` | user session |
| 9 | `onboarding.check_institution_link_status` | `checkForInstitutionLinkStatus` | user session |
| 10 | `onboarding.eval_user_qualification` | `evalUserQualification` | user session |

Today these are all GraphQL fields on the backend's `/graphql` endpoint.
This SDK is deliberately built so that can change — see
[Transport](#transport--why-this-is-built-to-move-off-graphql) below.

Wallets, identity, ledger, biometrics, and agents are also covered — see
[BitID-Core Proxy](#bitid-core-proxy) below. The partner OAuth2 + PKCE
sign-in flow is covered too — see [Sign-in](#sign-in) below. Everything else
the backend exposes (the rest of the app API — chat, cashboost, transactions
unrelated to bitID, etc.) is out of scope for this package on purpose.

> **`providerId` values**: `initiate_bank_connection`/`process_bank_connection`
> accept exactly `"AEROPAY"`, `"FLINKS"`, or `"FINICITY"` (case-sensitive).
> Any other value throws a plain uncoded backend `Error`, surfaced as an
> unmapped `GraphQLAPIError`.

> **`eval_user_qualification` is the one exception to `input=`**: it takes
> scalar arguments directly — `is_accept`, `account_id`,
> `cheat_sheet_sections`, `force` — rather than a single `input` dict like
> the other nine operations.

## Install

Published to **TestPyPI** as of `0.3.1`, not the real index yet:

```bash
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ bitid-sdk
```

(The `--extra-index-url` is needed because this package's dependencies, e.g.
`httpx`, aren't on TestPyPI — only `bitid-sdk` itself is. Older `pip`
versions have known bugs resolving multiple index URLs together; if a
version looks unavailable that you can otherwise see on
`https://test.pypi.org/project/bitid-sdk/`, upgrade `pip` before assuming
the release is broken.)

Or install from source: `pip install -e .` from a checkout of this repo.

> **Naming note:** `bitid-sdk` is the correct name — this project's
> `pyproject.toml` already publishes as `bitid-sdk`, matching this TestPyPI
> release. Real PyPI's `bitid-sdk` listing is currently just a placeholder
> Beem reserved there (confirmed live 2026-08-17); disregard its pointer
> elsewhere — it doesn't reflect this package's intended name.

## Authentication

Two credentials, used at different points in the flow:

- **Service-account key** (`bit_sk_...`) — a static key the backend issues
  per partner, sent as `x-service-account-key`. Required for steps 1–5,
  before a user exists to hold a session. Scoped per operation and
  rate-limited on the backend — get one from Beem, this SDK doesn't mint
  them.
- **User session** — the Cognito `IdToken` returned once the user logs in,
  sent as the raw `Authorization` header value (no `Bearer ` prefix — that's
  what the backend's auth check expects). Required for steps 6–10.

Pass **`autoLogin: True`** in `onboarding.verify_email_otp()`'s (step 5)
`input`, and once the OTP is verified, the session token is captured
automatically from the response and stored on the client — steps 6–10 then
just work, no extra plumbing. Without `autoLogin: True`, the OTP still
verifies but the response carries no session token to capture, so
`client.is_authenticated` stays `False`:

```python
from bitid_sdk import BitIDClient

client = BitIDClient(base_url="https://api.trybeem.com", service_account_key="bit_sk_...")

client.onboarding.verify_email_otp(input={"email": "ada@example.com", "otp": "654321", "autoLogin": True})
assert client.is_authenticated  # True — the session token is in now

client.onboarding.update_user(input={"address1": "1 Infinite Loop"})  # just works
```

If you already have a token from elsewhere (a refresh, a resumed session),
set it directly instead:

```python
client.set_user_session(id_token)
```

Calling a session-gated method before any session exists raises
`SessionNotAuthenticatedError` — client-side, before any network call.

A third, unrelated credential — the partner access token (`bpt_...`) —
authenticates `client.signin.me()`/`refresh()`. It has nothing to do with
either credential above: no service-account key or user session is required
to construct a `BitIDClient` and use `client.signin` alone. See
[Sign-in](#sign-in) below.

`client.signin.consent_and_exchange()` is the one exception to that
independence — it reuses the **same user session** as steps 6–10 above
(not a new, fourth credential), which is exactly what lets it skip the
consent webview: the session onboarding already captured *is* what
`bitidPartnerAuthorize`/`bitidPartnerConsent` need.

## Sign-in

The partner OAuth2 + PKCE flow: a third party's *own user*, who already has
a BitID identity, grants that third party's app scoped access to it. This is
a different population from onboarding above (which creates a brand-new
Beem user) — sign-in never creates an account, it only reads one that
already consented.

There are two ways to reach a scoped access token, depending on who holds
the Beem session:

| Method | Backend operation | Auth |
|---|---|---|
| `signin.authorize_url` | *(local — no call)* | n/a |
| `signin.exchange_token` | `bitidPartnerToken` | none — secured by `code` + PKCE `codeVerifier` (+ optional `clientSecret`) |
| `signin.me` | `bitidMe` | partner access token |
| `signin.refresh` | `bitidPartnerRefresh` | partner access token |
| `signin.consent_and_exchange` | `bitidPartnerAuthorize` → `bitidPartnerConsent` → `bitidPartnerToken` | the session `onboarding.verify_email_otp()` just captured |

The first four are the browser-mediated path shown below. `consent_and_exchange()`
is the other one — see [Minting a scoped token directly after onboarding](#minting-a-scoped-token-directly-after-onboarding-no-browser).

```python
from bitid_sdk import BitIDClient

client = BitIDClient(
    base_url="https://api.trybeem.com",
    authorize_base_url="https://web.trybeem.com/oidc/connect",  # a DIFFERENT
    # host from base_url — the consent webview is served by beem-portal, not
    # line-os-microservice. No service_account_key needed for sign-in alone.
)

req = client.signin.authorize_url(
    client_id="partner-app",
    redirect_uri="https://partner.example.com/callback",
    scopes=["bitid.identity.read"],
)
# Send the end user's browser to req.url. Persist req.code_verifier and
# req.state yourself (e.g. keyed by a server-side session id) until they're
# redirected back to redirect_uri with ?code=...&state=....

# -- later, handling that redirect --
client.signin.exchange_token(
    code=received_code,
    code_verifier=req.code_verifier,   # the value you persisted, not code_challenge
    client_id="partner-app",
    redirect_uri="https://partner.example.com/callback",
    # client_secret="..." only for confidential (server-held-secret) clients
)
# exchange_token() captured the access token automatically — these just work:
identity = client.signin.me()          # {did, verified, tier}
client.signin.refresh()                # rolls the token forward; the old one dies immediately
```

See `examples/signin_flow.py` for a runnable version of this (mocked, no
real network calls).

### Minting a scoped token directly after onboarding, no browser

> **✅ Verified live, 2026-08-18.** Confirmed end-to-end against `dev.useline.com`
> with a real onboarding session: `consent_and_exchange()` → real `bpt_...`
> access token → `me()` → `refresh()` (rotated to a new `bpt_...`) → `me()`
> again with the rotated token. This was blocked from 2026-08-17 16:28 IST
> until 2026-08-18 by a real backend regression (`os` commit `e39745b25`
> accidentally added `bitidPartnerAuthorize`/`bitidPartnerConsent` to
> `handler.ts`'s no-auth-needed allowlist, so `context.user` was never
> populated for them even though their own resolver code requires it — every
> caller, including the real consent webview, would have hit
> `BITID_PARTNER_LOGIN_REQUIRED` during that window, not just this SDK).
> Fixed in commit `d1018e81d`; no changes were needed on this SDK's side —
> the request was correct throughout.

`consent_and_exchange()` is for one specific situation: **your own backend**
just ran the [Onboarding](#quickstart) flow above and wants a scoped BitID
token for the person it just created, without sending them through the
consent webview a second time. `verify_email_otp()` already captured that
new user's Cognito ID token onto the session — the same credential
`bitidPartnerAuthorize`/`bitidPartnerConsent` need — so this runs all three
steps (`bitidPartnerAuthorize` → `bitidPartnerConsent(approve=True)` →
`bitidPartnerToken`) back-to-back, server-side:

```python
client.onboarding.check_existing_email_phone(input={...})
# ... steps 2-4 ...
client.onboarding.verify_email_otp(input={"email": ..., "otp": ..., "autoLogin": True})
# session now has an id_token — this works with no extra wiring:

client.signin.consent_and_exchange(
    client_id="your-partner-client-id",     # required — your OWN registered client, no default
    redirect_uri="https://you.example.com/callback",  # required, must match your client's registration
    scopes=["bitid.identity.read", "bitid.wallet.read"],  # what you're asking for AND what gets granted
)
# captures the access token automatically, same as exchange_token() — these just work:
identity = client.signin.me()
```

**This is implicit consent** — nobody sees "`<you>` wants: `<scopes>`" and
taps Allow; the person being onboarded through your backend stands in for
that. `scopes` here is both the request *and* the grant — unlike the
webview, there's no screen for the person to narrow it down themselves.
Confirm that trade-off is right for your integration before reaching for
this instead of the normal `authorize_url()` browser round-trip.

`client_id`/`redirect_uri` have no default — pass your own on every call.
On denial (shouldn't normally happen, since this always sends
`approve: True`) it raises `BitidPartnerConsentDeniedError` immediately,
same as every other failure mode here — see [Errors](#errors) below, which
now also covers `bitidPartnerAuthorize`/`bitidPartnerConsent`'s own codes.

### Deliberately not here

- **`bitidSignIn`** — checked against a first-party client allowlist on the
  backend and rejected for every other `client_id`. Not callable by a third
  party; this is what Beem's own app uses to sign itself in.
- **`bitidPartnerGrants` / `bitidPartnerRevokeGrant`** — also gated on the
  Beem user session; these back Beem's own "manage connected apps" screen,
  not something a partner backend calls on a user's behalf.

### Errors

Sign-in errors are still ordinary `GraphQLAPIError` subclasses (unlike Core
Proxy's embedded-status errors below) — mapped from
`os/line-os-microservice/src/modules/enum/errors.ts`'s BitID partner-OIDC
block:

```python
from bitid_sdk import (
    BitidPartnerUnauthorizedError,          # token invalid/expired/revoked
    BitidPartnerForbiddenOperationError,    # token sent to a non-BitID field
    BitidPartnerScopeDeniedError,           # grant doesn't include the needed scope
    BitidPartnerUserMismatchError,
    BitidPartnerUnknownClientError,         # unknown/inactive client_id
    BitidPartnerInvalidRedirectError,       # redirect_uri not registered for client_id
    BitidPartnerInvalidScopeError,
    BitidPartnerInvalidPkceError,           # code_challenge/code_verifier mismatch
    BitidPartnerInvalidClientError,         # bad/missing client_secret
    BitidPartnerInvalidGrantError,          # code invalid, expired, or already used
    BitidPartnerIdentityUnavailableError,
    PartnerTokenNotSetError,                # raised client-side, no network call made
    # From consent_and_exchange()'s own bitidPartnerAuthorize/bitidPartnerConsent calls:
    BitidPartnerLoginRequiredError,         # session expired mid-flow
    BitidPartnerConsentInvalidError,        # consentToken malformed, expired, or reused
    BitidPartnerConsentUserMismatchError,   # session changed user between authorize and consent
    BitidPartnerConsentDeniedError,         # client-side only -- consent came back denied
    SessionNotAuthenticatedError,           # client-side, no network call -- no id_token set yet
)

try:
    client.signin.exchange_token(code=..., code_verifier=..., client_id=..., redirect_uri=...)
except BitidPartnerInvalidGrantError:
    ...  # the code was single-use and is now spent — restart from authorize_url(), not a retry
```

`BitidPartnerInvalidGrantError` in particular means the `code` is dead —
codes are single-use, so recovering means a fresh `authorize_url()`
round-trip, not retrying `exchange_token()` with the same `code`.

### Verified live

Confirmed against `dev.useline.com` on 2026-08-17, the full chain —
`exchange_token()` → `me()` → `refresh()` → `me()` again with the rotated
token — succeeded end-to-end using a real, freshly-issued one-time code
(minted via `bitidPartnerAuthorize`/`bitidPartnerConsent`, called directly
with a real Beem Cognito session rather than through the consent webview —
see the next paragraph for why). That manual call sequence is exactly what
`consent_and_exchange()` above now productizes. Confirms live: the exact GraphQL shapes
above, the real access-token prefix `bpt_...` (not `bat_`, despite that
being what `bitid-sdks`' own JS comments say), the one-time code prefix
`bpc_...`, and that `refresh()` truly rotates rather than issuing a second
valid token alongside the first.

**Not verified live: the consent webview itself.** `authorize_url()`
builds a correctly-shaped URL, but neither candidate host/path
(`https://dev-web.trybeem.com/oidc/authorize` or `.../oidc/connect` — the
latter is what `beem-app-3.0` actually uses) resolves to a live page as of
2026-08-17; both 404. The host itself is real (`beem-portal`'s own
`.env.development` confirms `NEXT_PUBLIC_WEBSITE_URL=https://dev-web.trybeem.com`),
so this looks like the consent-page routes aren't deployed there yet,
rather than a wrong host. Until that's resolved, treat `authorize_url()`'s
output as correctly-constructed but unverified in the browser — the
backend calls it depends on (`bitidPartnerAuthorize`/`bitidPartnerConsent`)
are what's actually been confirmed working, by calling them directly.

## VGS tokenization

`phoneNumber` and `email` must already be VGS vault tokens (`tok_...`) by
the time they reach `save_phone_and_send_otp`, `verify_phone_otp`,
`create_line_user`, and `verify_email_otp` — raw values fail outright
(confirmed against dev on 2026-08-10: `save_phone_and_send_otp` rejects a
raw phone with `InvalidPhoneError`). `check_existing_email_phone` is the one
exception — raw values work fine there. `dob`/`ipAddress`/everything else
stays raw.

There's no GraphQL operation for this (checked the full schema — none
exists). It's a plain HTTPS POST to a VGS-fronted domain, traced from how
`beem-app-3.0`'s own tokenizer works:

```python
tok_phone = client.vgs.tokenize_phone("+15551234567")   # -> "tok_..."
tok_email = client.vgs.tokenize_email("ada@example.com") # -> "tok_..."
```

Requires `vgs_webhook_url` on `BitIDClient` — there's no default, since the
only confirmed value is a dev/sandbox one
(`bitid_sdk.resources.vgs.DEV_VGS_WEBHOOK_URL`,
`https://secure-dev.useline.com/vgsWebhook`); get the production URL from
whoever owns env config before using this against production.

**Deliberately a separate, explicit step**, not something
`save_phone_and_send_otp`/`create_line_user` do silently — the entire point
of VGS is that raw PII never has to transit an app's own backend. Calling
`client.vgs.tokenize_*` from a partner's *server* (as the quickstart below
does, for simplicity) still means this SDK's process briefly holds the raw
value. The more correct integration is for the partner's own client-side
code to tokenize before the value ever reaches their backend — using this
same webhook directly, or VGS Collect — and hand this SDK only
already-tokenized values, exactly as `save_phone_and_send_otp` etc. already
require.

## Quickstart

```python
from bitid_sdk import BitIDClient

with BitIDClient(
    base_url="https://api.trybeem.com",
    service_account_key="bit_sk_...",
    vgs_webhook_url="https://secure-dev.useline.com/vgsWebhook",  # dev/sandbox only -- see VGS tokenization
) as client:
    client.onboarding.check_existing_email_phone(input={"phone": "+15551234567"})

    tok_phone = client.vgs.tokenize_phone("+15551234567")
    client.onboarding.save_phone_and_send_otp(input={"phoneNumber": tok_phone})
    client.onboarding.verify_phone_otp(input={"phoneNumber": tok_phone, "otp": "123456"})

    # phoneNumber, email, ipAddress, and dob (YYYY-MM-DD) are all mandatory --
    # a missing one raises InputValidationError naming which field.
    tok_email = client.vgs.tokenize_email("ada@example.com")
    user = client.onboarding.create_line_user(input={
        "firstName": "Ada", "lastName": "Lovelace", "phoneNumber": tok_phone,
        "email": tok_email, "dob": "1990-01-01", "ipAddress": "203.0.113.5",
    })

    client.onboarding.verify_email_otp(input={"email": tok_email, "otp": "654321", "autoLogin": True})

    client.onboarding.update_user(input={"address1": "1 Infinite Loop"})
    # providerId must be exactly "AEROPAY", "FLINKS", or "FINICITY" -- see
    # "Known backend gaps" below for why other values fail generically.
    link = client.onboarding.initiate_bank_connection(input={"providerId": "AEROPAY"})
    client.onboarding.process_bank_connection(input={"providerId": "AEROPAY", "data": {"...": "..."}})
    client.onboarding.check_institution_link_status()
    client.onboarding.eval_user_qualification(is_accept=True)
```

See `examples/onboarding_flow.py` for a runnable version of this (mocked,
no real network calls).

### Inputs are pass-through dicts, on purpose

Every `input=` argument mirrors the backend's own GraphQL input type
(`EmailPhone`, `cognitoInput`, `UserInput`, ...) directly as a plain dict —
this SDK doesn't maintain a parallel model of those shapes. `UserInput` in
particular is a large, shared, evolving type on the backend; re-declaring it
here would drift. Pass whatever keys the backend documents for that field.

### Field selection: `create_line_user` and `update_user` use different defaults

Both return a `User` object, but their resolvers hand back different
shapes, so each has its own default field selection tuned to match:

- `update_user`'s resolver returns a full `User` document, so it defaults to
  a broad profile selection: `transport.graphql.DEFAULT_USER_FIELDS`.
- `create_line_user`'s resolver returns a compact, purpose-built object —
  `success`, `userSub`, `userId`, `emailOtpResponse`, `prefillError` — the
  same fields the real Beem app's own `createLineUser` GraphQL document
  requests. Its default, `transport.graphql.DEFAULT_CREATE_LINE_USER_FIELDS`,
  matches that shape exactly.

Pass `fields=[...]` to either method for a custom selection; for
`create_line_user`, stay within the fields that resolver actually returns.

```python
client.onboarding.update_user(input={...}, fields=["userId", "email", "phoneVerified"])
```

Note `update_user`'s default uses `userId`, not `id` — the schema declares
`User.id` non-nullable, but this resolver doesn't set it.

## Error handling

All SDK errors inherit from `BitIDError`.

```python
from bitid_sdk import (
    GraphQLAPIError,                       # base for backend-rejected requests; .code, .raw_errors
    ServiceAccountKeyMissingError,          # no key sent
    InvalidServiceAccountKeyError,          # unknown/revoked key
    ServiceAccountKeyExpiredError,
    ServiceAccountScopeDeniedError,         # key isn't scoped for this operation
    ServiceAccountRateLimitExceededError,
    ServiceAccountUsersBlockedError,
    AuthenticationFailedError,              # bad user session token
    SessionExpiredError,
    AccountNotFoundError,
    InvalidPhoneError,
    NotMobileError,
    OtpNotRequestedError,
    InputValidationError,
    SessionNotAuthenticatedError,           # raised client-side, no network call made
    NetworkError,                           # request never reached the server
    TransportError,                         # non-GraphQL-shaped HTTP failure
)

try:
    client.onboarding.save_phone_and_send_otp(input={"phoneNumber": "+15551234567"})
except ServiceAccountScopeDeniedError as exc:
    print(exc.code, str(exc))
except GraphQLAPIError as exc:
    print("unmapped backend error", exc.code, exc.raw_errors)
```

A backend error code with no dedicated subclass still raises as
`GraphQLAPIError` (with `.code` set) rather than being silently swallowed or
crashing with a `KeyError` — new codes on the backend degrade gracefully
here.

Network errors and 5xx responses are retried automatically with exponential
backoff (`max_retries=3` by default, configurable on `BitIDClient`). GraphQL
application errors (a 200/400 with an `errors` array) are never retried —
they're not transient.

## Known backend gaps

### The `origin` header (`create_line_user` / `check_existing_email_phone`)

These two operations share their underlying resolvers with the native Beem
app, and those resolvers run an app-version check — a gate meant for the
app, not for service-account-authenticated partner traffic (which these two
operations already require independently). A partner backend has no app
version, so without a workaround both calls fail with `UNSUPPORTED_OPERATION`
("Unsupported operation, please update app.").

That same gate also accepts a request whose `origin` header matches one of a
small first-party allowlist. **This SDK sends `origin: https://useline.com`
on every request by default** (`transport.graphql.DEFAULT_APP_VERSION_GATE_ORIGIN`)
as a deliberate stopgap — it works by presenting as Beem's own web frontend,
not because the backend recognizes partner traffic as such. It's not a
normal part of the wire protocol and shouldn't be treated as one.

```python
# Disable it (e.g. once the backend stops requiring it):
client = BitIDClient(base_url=..., service_account_key=..., app_version_gate_origin=None)

# Or override the value:
client = BitIDClient(base_url=..., service_account_key=..., app_version_gate_origin="https://www.trybeem.com")
```

`useline.com` works as a default today but isn't a BitID identity long-term.
**Planned:** once the backend's allowlist adds a BitID-branded origin (e.g.
`https://bitid.org`, not present today), switch this SDK's default to that
instead. Callers who want that sooner can already pass
`app_version_gate_origin="https://bitid.org"` themselves once the backend
allowlist supports it — no SDK change required for that part.

**The underlying fix belongs on the backend**: stop applying the app-version
gate to these two operations for service-account-authenticated traffic
(mirroring how the other service-account operations already skip it), or
exempt service-account-authenticated requests from that check entirely.
Once that lands, flip this SDK's default to `None` and drop this section.

### `update_user`'s `address1` doesn't come back set

Confirmed live against `dev.useline.com` on 2026-08-17, full onboarding
run: `client.onboarding.update_user(input={"address1": "1 Infinite Loop"})`
against a real, freshly-authenticated session returned `address1: None` —
`phoneVerified`/`emailVerified` on the same response correctly reflected
the account's real state, so the call itself succeeded and the session was
accepted; only this one field didn't take (or isn't echoed back even when
set). Not yet root-caused — possibly this resolver expects a fuller address
object (city/state/zip together) rather than `address1` alone. If your
integration depends on setting/reading `address1` specifically, verify
against a live account before relying on it.

## BitID-Core Proxy

Crypto-wallets and machine-agent-layer (`ds-m2m`), reached through the exact
same `/graphql` endpoint and service-account key as onboarding above —
`bitidCoreProxyGet`/`bitidCoreProxyPost` proxy a fixed allowlist of REST
paths on those backends, so this SDK never opens a second host or needs a
second credential. The full allowlist (path, required scope, purpose) is in
`BitID-Core Proxy — API Reference.md`; `src/bitid_sdk/core_proxy.py` is the
one file that knows the proxy mechanics.

| Resource | Methods | Covers |
|---|---|---|
| `client.identity` | `create_did`, `get_did`, `get_semaphore_root`, `prove_claim`, `verify_proof`, `verify_user` | Semaphore identity signup + ZK proof |
| `client.wallets` | `create`, `get_user_assets`, `get_user_wallets` | BTC/ETH/CREATE2 wallet creation + lookup |
| `client.balance` | `get_balance` | BID shadow balance (see note below) |
| `client.ledger` | `list_events`, `get_event`, `verify_event`, `list_event_types` | Read-only audit history |
| `client.authid` | `register_face`, `verify_liveness` | Biometric enrollment/check |
| `client.agents` | `create_human_did`, `create_machine_did`, `delegate`, `sub_delegate`, `enroll_human`, `enroll_agent`, `group_root`, `merkle_path`, `nullifier`, `revoke`, `revocation_status`, `revocations`, `authorize` | Machine-agent DIDs, delegation credentials, Semaphore groups, revocation, gateway authorize |
| `client.diagnostics` | `health_check`, `get_circuit_status` | Status probes only — see note below |

```python
client = BitIDClient(base_url="https://api.trybeem.com", service_account_key="bit_sk_...")

client.identity.create_did(user_id)
client.wallets.create(user_id)

human = client.agents.create_human_did(label="alice")
machine = client.agents.create_machine_did(controller_did=human["human_did"], label="agent-1")
client.agents.delegate(human_did=human["human_did"], agent_did=machine["machine_did"], scopes=["pay.send"])
```

### Not exposed, on purpose

- **`treasury`, `wallet_summary`, QR sessions** — not on the Core Proxy
  allowlist at all (REST-ALB-only, a separate internal host with no public
  route). Confirmed against `BitID-Core Proxy — API Reference.md`; ask if
  one of these needs to be added to the allowlist.
- **Semaphore-root Bitcoin inscription** (`inscribe`, `confirm_inscription`,
  `inscription-status`, `check-onchain-status`) — the backend supports these,
  but they trigger a real, cost-bearing, quarterly on-chain BTC transaction.
  Deliberately not wrapped here; a general partner integration shouldn't be
  able to call them. `diagnostics.py` only exposes the two endpoints with no
  cost or side effect (`health_check`, `get_circuit_status`).
- **`balance.redeem`** — commented out in `resources/shadow_balance.py`
  (`POST /bid-balance/balance/{user_id}/redeem`). BID redemption isn't fully
  activated/used yet; the request/response models already exist, ready to
  re-enable once it is.

### Errors work differently here than in onboarding

Onboarding errors are GraphQL-level — a real `errors[]` entry, mapped by
`code` to a `GraphQLAPIError` subclass. Core Proxy is different: confirmed
live against `dev.useline.com`, a downstream failure (e.g. a 409 conflict)
comes back as an ordinary `200`, no GraphQL error, with the *downstream*
service's own `status_code`/`success`/`error` embedded in otherwise-normal
data. `core_proxy.py` inspects that embedded status and raises a
`CoreProxyError` subclass instead:

```python
from bitid_sdk import CoreProxyError, DidAlreadyExistsError, WalletAlreadyExistsError

try:
    client.identity.create_did(user_id)
except DidAlreadyExistsError:
    ...  # the one case with its own subclass -- also raised for a
         # backend-observed 500 duplicate-key race on the same condition,
         # not just a clean 409 (see identity.py's _looks_like_already_exists)
except CoreProxyError as exc:
    print(exc.path, exc.status_code, exc.payload)
```

One caveat found during manual testing: not every endpoint's failure is
`status_code`-enveloped this way. A malformed `enroll_human`/`enroll_agent`
call, for instance, came back as a bare deserialization-error *string*, not
the `{status_code, success, error}` shape the rest of this backend uses —
that won't raise a typed exception, it'll just return the string as the
"successful" result. Check the shape of what you get back, especially from
less-exercised endpoints.

### Other things found testing this live

- **`identity.prove_claim`/`verify_user` need pre-existing trust-score
  data** — calling either against a brand-new user with no prior activity
  fails 422 ("No trust score found... write to `trustscores` first"). Not
  usable as a bare identity check for an arbitrary fresh user.
- **`enroll_human`/`enroll_agent` expect `human_did=`/`agent_did=`** as the
  body field names (both are plain `**body` passthroughs, undocumented
  shape beyond the allowlist doc) — not `did=`.
- **`create_did` can 500 with a Mongo `E11000 duplicate key` error on
  `semaphore_tree_leaves.leaf_index`** even for a genuinely new user —
  looks like leaf-index contention under load, not strictly a duplicate-user
  condition. `create_did`/`get_did` treat it the same as a clean 409
  (`DidAlreadyExistsError`); `get_did` recovers cleanly either way.

## Transport — why this is built to move off GraphQL

Every resource method goes through one seam:
`Transport.execute(operation, variables)` (see `transport/base.py`). No
resource method builds a GraphQL document, names a header, or imports
`httpx` — only `transport/graphql.py` does, and it's the *only* file in this
package that knows GraphQL exists.

The onboarding surface is expected to move to REST at some point. When it
does: add a `RestTransport` implementing the same `Transport` interface
(map each `Operation.name` to a method + path instead of a document; keep
applying `ServiceAccountKeyAuth` / `UserSessionAuth` exactly as
`GraphQLTransport` does — *which* credential an operation needs doesn't
change, only how it's carried), then construct the client with
`BitIDClient(transport=RestTransport(...))` instead of the default. Every
`onboarding.*` call, every exception, every example above keeps working
unchanged.

## Layout

```
src/bitid_sdk/
  client.py              BitIDClient — the entry point
  session.py              Session — mutable holder for the user's ID token
                            and the partner access token
  auth.py                 ServiceAccountKeyAuth, UserSessionAuth,
                            UnauthenticatedAuth, PartnerTokenAuth
  operations.py            Operation/AuthRequirement registry (transport-independent)
  exceptions.py            BitIDError hierarchy, mapped from backend error codes
  pkce.py                  generate_pkce_pair() — RFC 7636 S256, dependency-free
  core_proxy.py            BitID-Core Proxy path builders + GET/POST helper + status mapping
  transport/
    base.py                 Transport interface — the swap-to-REST seam
    graphql.py               GraphQLTransport — the only GraphQL-aware file
  models/                  Pydantic request/response models -- Core Proxy resources only;
                             onboarding stays plain dicts, see "Inputs are pass-through dicts"
  resources/
    onboarding.py            OnboardingResource — one method per operation
    signin.py                 SignInResource — authorize_url/exchange_token/me/refresh
    vgs.py                    VgsResource — phoneNumber/email tokenization (not GraphQL)
    identity.py              IdentityResource — Semaphore identity + ZK proof
    wallets.py                WalletsResource — wallet create/lookup
    shadow_balance.py         ShadowBalanceResource — BID balance (redeem commented out)
    ledger.py                 LedgerResource — read-only audit history
    authid.py                 AuthIdResource — biometrics
    agents.py                 AgentsResource — machine-agent DIDs/delegation/revocation
    diagnostics.py            DiagnosticsResource — health_check + circuit status only
examples/
  onboarding_flow.py         Runnable walk-through (respx-mocked)
  signin_flow.py              Runnable walk-through (respx-mocked)
tests/
  test_onboarding.py         Transport + resource tests (respx-mocked)
  test_signin.py              SignInResource tests (respx-mocked)
  test_vgs.py                 VgsResource tests (respx-mocked)
  test_core_proxy.py          Core Proxy plumbing tests (respx-mocked)
  test_resources_*.py         One file per Core Proxy resource (respx-mocked)
```

## Development

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

pytest              # unit tests (respx-mocked HTTP, no network calls)
ruff check src tests
mypy src/bitid_sdk
```

Tests never hit a real backend — every HTTP call is mocked with
[respx](https://github.com/lundberg/respx).
