Skip to main content
Customers who bring no address of their own get one from the Endstate wallet. The wallet runs in a hidden iframe on an Endstate origin; the SDK mounts that frame, establishes a session from your identity credential, and reports the account. Key material never reaches your page. In React, useWallet reads that account and its provisioning status, and the EndstateProvider you already wrap your tree in takes the wallet config. The wallet provisions and reports the account and nothing more - it exposes no signing surface of its own, because a claim is signed by Endstate and a transfer is broadcast by the current owner’s own signer. The account address is the whole of what a customer’s claim needs: it is the recipient of a claim, the step the browser quickstart walks end to end. Capturing and verifying the tap that opens that claim is separate and carries none of the wallet’s requirements - see Tap capture.

How provisioning works

The wallet is not a library that runs in your page. It is a document Endstate hosts, loaded into a hidden frame, that your page talks to over a narrow, origin-checked message bridge:
  • The frame is on an Endstate origin. Once the wallet is enabled, the SDK appends a hidden iframe pointed at https://wallet.endstate.io. Because the wallet’s own document owns the key material, that material is isolated from your page by the browser’s origin boundary. Your page can ask for an account and a session; it can never read a private key.
  • The origins are compiled in. The frame origins ship inside the published bundle and are not configurable. Every message the bridge sends is addressed to that one origin, and every message it accepts is checked against it. This is a security property of the design, not a default you can override.
  • There is no signing surface. The account address is the whole of what the wallet exposes. Nothing in the API accepts bytes to sign or a transaction to authorize.
The wallet is document-scoped - it lives entirely client-side. In React, the provider subtree that owns the wallet must be a client component ("use client"); it never mounts on the server. In Vanilla, createWallet throws if window or document is absent.

Provisioning the wallet

Wrap your tree in EndstateProvider once and hand it the wallet config; useWallet then reads the account anywhere inside. The config is an EndstateWalletConfig: getIdentityToken supplies the identity credential each session is established from, and enabled gates provisioning on your sign-in state. There is no environment to set. useWallet() returns { account, status, error, ready, refresh, expiresAt }:
  • account - the WalletAccount ({ address }), or null until provisioning finishes.
  • status - a WalletStatus: "unconfigured", "idle", "provisioning", "ready", or "error". It stays "unconfigured" while the wallet is not enabled, moves through "provisioning" as the frame mounts and the first session is established, and lands on "ready" once the account exists.
  • error - the failure when status is "error", otherwise null.
  • ready() - resolves with the WalletAccount once it exists; await it where you first need the address.
  • refresh() - establishes a fresh session and resolves with the account.
  • expiresAt - a Date for when the current session lapses, or null before it is ready.
The Vanilla path is the same lifecycle without a provider: createWallet returns an EndstateWallet immediately, with the frame already loading behind it, and you await wallet.ready() where the address is needed.
The wallet config takes:
() => string | Promise<string>
required
Supplies the identity credential each session is established from. Called fresh every time, because the credential is single-use and short-lived, and only once enabled is true.
boolean
Gates provisioning on your sign-in state. While it is false the wallet stays unconfigured and getIdentityToken is never called; set it true once the customer is signed in and the wallet provisions.
HTMLElement
Where the hidden frame is appended. Defaults to document.body.
{ frameReadyMs?: number; sessionMs?: number }
Wait budgets in milliseconds. Defaults: frameReadyMs 5000, sessionMs 30000.

Gating on sign-in

getIdentityToken runs only once enabled is true. Point enabled at your sign-in state - with BetterAuth, enabled: session != null, or your own isSignedIn flag - and the wallet stays unconfigured for signed-out visitors, calls nothing, and provisions the moment they sign in. Because the callback fires only behind that gate, a signed-out user never triggers a mint against your identity endpoint, and the credential is requested exactly when a session is needed.

The identity credential

getIdentityToken is the one integration point you own. It returns a dedicated, single-use identity credential for the signed-in customer: a short-lived JWT, expiring in about a minute, that carries the customer’s identity and is neither a session token nor a token used for anything else. That short life is what makes handing it to a browser acceptable - it is useless anywhere else, and anything else is useless here. Endstate expects a JWT signed by your registered key, carrying the customer’s identity. Its decoded payload:
Because it is single-use, the callback is invoked fresh for every session establishment - the first time the wallet provisions, and every refresh - so it must never return a cached value. It must resolve with a non-empty string; anything else throws a TypeError. It runs under the session wait budget, so a slow backend times out as wallet.timeout rather than hanging. Where the token comes from depends on how your customers sign in:
  • Bring your own auth. Your backend mints and signs it from your own login. See Bring your own auth for the exact claim contract - the accepted algorithms, the 300-second age ceiling, and the JWKS Endstate verifies against - and how to register your issuer.
  • Endstate-managed identity. Endstate issues the credential against the customer’s Endstate sign-in, so the page must be an Endstate-hosted surface. You still return it through the same callback.

Refreshing and expiry

A session is time-bounded. In React, useWallet().expiresAt tells you when it lapses and refresh() establishes a new one; in Vanilla the same pair is wallet.sessionExpiresAt() and wallet.refreshSession(). Either calls getIdentityToken again for a fresh single-use credential, so your callback must be ready to issue another. Check expiresAt to refresh ahead of expiry rather than waiting for a call to fail, and treat a refresh as the remedy for wallet.session_expired: when a call fails with that code, refresh and retry.

Error handling

Every wallet failure carries an EndstateWalletError with a stable code. Branch on code, never on message - messages are for humans and may change. In React the error surfaces on useWallet().error; in Vanilla the lifecycle methods reject with it. Either way it is the same object.
code is one of three kinds:
  • A wallet.* code from the frame, such as wallet.session_expired.
  • An API error code, forwarded verbatim from the Endstate API.
  • A code this package produced locally: wallet.timeout (no response within budget), wallet.destroyed (teardown settled the call), and - when the wallet’s reply could not be used - wallet.unsupported_protocol_version or wallet.internal_error.
wallet.unsupported_protocol_version means the frame speaks a protocol this build does not; upgrade @endstate-sdk/web.
string
The stable, branchable identifier for the failure.
string
Human-readable detail. Do not branch on it.
string
Documentation for this code, when the failure carried one.
string
Present when the failure originated at the Endstate API. Quote it in support requests.
Record<string, unknown>
Structured context for the failure, when the wallet supplied any.

Next steps

Claim ownership

Send the item to the account address this wallet provisions.

createWallet

The Vanilla signature and options for mounting a wallet frame.

EndstateWalletError

Every code the wallet can throw and what to do with it.

Bring your own auth

Mint the identity credential from your own login.