> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endstate.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Transfer ownership

> Move an owned item to a new owner from the browser with React or the vanilla client: prepare the transfer, have the current owner broadcast it, settlement, and idempotency, with @endstate-sdk/web.

A transfer **moves an item between existing owners**. The item already has an
owner, and a recent tap of its chip - proof someone is holding it - authorizes
handing it to someone new. You supply the recipient's wallet `address`, and the
session token the tap opened authorizes the move.

That is what sets a transfer apart from a [claim](/sdks/web/claim), which is the
*first* assignment of ownership out of a tap. A claim has no prior owner, so
Endstate can submit it for you. A transfer has one, so **only that current owner
can complete it**: Endstate authorizes the move and hands back a transaction, but
never moves an item on an owner's behalf.

## The flow

React is the primary path. `useTransfer()` gives you a `transfer({ to })` call
bound to the verified session the landing tap opened, and `useSession()` holds
the item being moved. The [browser quickstart](/sdks/web/quickstart) covers how
the tap is captured and verified, and [Tap sessions](/sdks/core/tap-sessions)
covers what the session is. Prefer the hooks; the vanilla
`session.transfers.create` is the same call without React.

<CodeGroup>
  ```tsx React theme={null}
  "use client";
  import {
    EndstateProvider,
    useSession,
    useTransfer,
  } from "@endstate-sdk/web/react";

  // Mount the provider once, above the tree. With autoVerify on (the default) the
  // landing tap is captured and verified on mount, so useSession() already holds
  // the item being moved.
  function App() {
    return (
      <EndstateProvider publishableKey="end_pk_...">
        <TransferPanel />
      </EndstateProvider>
    );
  }

  function TransferPanel() {
    const { item } = useSession();
    const { transfer, status, data, error } = useTransfer();

    async function onTransfer() {
      const address = "0x..."; // where the item is going
      // unit_id comes from the session scope, so you pass only the recipient.
      // wait defaults false: Endstate prepares and authorizes the transaction and
      // hands it back - it never submits a transfer for you.
      const prepared = await transfer({ to: address });
      prepared.status; // "prepared"

      // The current owner broadcasts prepared.transaction with their own signer.
    }

    return (
      <div>
        <button onClick={onTransfer} disabled={status === "preparing"}>
          Transfer {item?.name ?? "item"}
        </button>
        {data && <p>Transfer {data.status}</p>}
        {error && <p role="alert">{error.message}</p>}
      </div>
    );
  }
  ```

  ```ts Vanilla theme={null}
  // `session` came from a captured, verified tap of the item being moved.
  // `recipientAddress` is where the item is going.
  const transfer = await session.transfers.create({ to: recipientAddress });
  transfer.status; // "prepared"

  // Endstate authorized the move but never submits a transfer for you. The
  // current owner broadcasts transfer.transaction with their own signer.
  ```
</CodeGroup>

`transfer({ to })` resolves as soon as Endstate prepares and authorizes the
transaction, returning the `Transfer` with its `transaction` attached. The hook
exposes that result as `data`, any failure as `error`, and where the operation
stands as `status` - a `TransferStatus` of `idle`, `preparing`, `settling`,
`confirmed`, `expired`, `failed`, or `error`. It starts `idle`, moves to
`preparing` while the request is in flight, and lands on `error` if the request
throws. Because `wait` defaults to `false`, the hook stops there: it does not
poll settlement, since a transfer cannot settle until the current owner
broadcasts the returned transaction, which the hook never does. Read the
returned transfer's own `status` (`prepared`) and observe settlement out of
band. Passing `wait: true` continues through `settling` to `confirmed`,
`expired`, or `failed`, but only after the owner has broadcast - so most
transfers leave it `false`.

A transfer takes one field:

<ParamField body="to" type="string" required>
  The recipient's EVM wallet address (`0x` followed by 40 hex characters) - the
  account that will receive the item. If the recipient is getting an Endstate
  wallet on this page, this is the address from the [wallet](/sdks/web/wallet)
  (`(await wallet.ready()).address`, or `useWallet().account?.address` under the
  provider).
</ParamField>

<Note>
  The transfer reads `unit_id` from the session's scope, so you never pass it. A
  session freshly opened by `verify()` - including the landing tap `autoVerify`
  captures - already carries that scope; one you [adopted from a bare
  token](/sdks/core/tap-sessions#adopting-a-session-you-already-hold) does not,
  and the call throws `EndstateSessionScopeError` until the session's scope is
  refreshed.
</Note>

## Choosing who submits

A transfer is always put through by the **current owner** - Endstate authorizes
the move but never submits it for you, and unlike a claim there is no
Endstate-submitted mode and no `execution` choice. The move is rejected unless
the owner's own account sends it. What is changing is the signer the owner
reaches for.

### Endstate wallet (coming soon)

An owner who holds an Endstate wallet will broadcast the transfer straight from
it, with no separate wallet library. The Endstate web wallet provisions an
`address` today but **exposes no signing surface; signing operations arrive in a
later minor release**. Until then, the owner broadcasts with their own signer,
below.

### The current owner broadcasts

`transfer` (and vanilla `session.transfers.create`) returns a `transaction`
object, `{ to, data }`, that Endstate has already authorized with its signature
and leaves for the current owner to send. The owner is the `from` address on the
returned transfer, and the move is rejected unless that same account sends it -
no relayer or other funded account can stand in. Endstate never submits a
transfer, so the current owner broadcasts the returned transaction with their
own signer, then reads settlement from the transfer record. The exact
`transaction` shape and how to broadcast it live in
[`session.transfers.create`](/sdks/core/reference/session-transfers/create).

Broadcast well within about 30 minutes of preparing the transfer: the signed
authorization expires after that and the transfer settles `expired`.

## Settlement

A transfer is created as `prepared` and lands on one of three terminal statuses:

* **`prepared`** - authorized and waiting for the current owner to broadcast it.
* **`confirmed`** - done. Ownership has moved and settled.
* **`expired`** - the signed authorization's window (about 30 minutes) passed
  before the owner broadcast it. Create a new transfer.
* **`failed`** - the transaction did not settle. Create a new transfer.

In React, read this from `useTransfer().data.status`, or opt into hook-driven
waiting with `transfer({ to, wait: true })` once the owner has broadcast. With
the vanilla client, `session.transfers.waitUntilSettled` polls until the status
is terminal so you do not write the loop:

```ts theme={null}
const settled = await session.transfers.waitUntilSettled(transfer.id);
settled.status; // "confirmed" | "expired" | "failed"
```

It reads the transfer on a backoff - starting at 1s, doubling to a 5s ceiling,
and giving up at 120s by default - all overridable through its options. To poll
yourself instead, call `session.transfers.get(transferId)`, or
`endstate.transfers.get(unitId, transferId)` from a secret-key client on your
backend. On `expired` or `failed`, create a new transfer.

## Idempotency

Creating a transfer is a mutation, so it is safe to retry with an idempotency
key. Pass `idempotencyKey`; omit it and core generates one for you. Replaying
the same key with the same body returns the original transfer rather than
opening a second one; the same key with a different body is rejected as a
conflict.

```tsx theme={null}
await transfer({
  to: recipientAddress,
  idempotencyKey: "transfer-order-12345",
});
```

With the vanilla client the key rides in the second options argument:
`session.transfers.create({ to }, { idempotencyKey })`.

## Errors

Every failure throws a typed error carrying a branchable `code`; in React it
surfaces as `useTransfer().error`. For how those errors behave and what retries
automatically, see [Errors and retries](/sdks/core/errors-and-retries); for the
full transfer code list - `unit.not_minted`, `transfer.owner_unknown`,
`transfer.in_progress`, `transfer.already_to_recipient`,
`session_token.wrong_chip`, and the rest - see
[`session.transfers.create`](/sdks/core/reference/session-transfers/create).

## Next steps

<CardGroup cols={2}>
  <Card title="session.transfers.create" icon="arrow-right-left" href="/sdks/core/reference/session-transfers/create">
    The transfer request body, response, transaction shape, and every error
    code.
  </Card>

  <Card title="Claim ownership" icon="hand" href="/sdks/web/claim">
    Assign first ownership out of a tap - the sibling action to a transfer.
  </Card>

  <Card title="Wallet" icon="wallet" href="/sdks/web/wallet">
    Provision the recipient address a transfer sends the item to.
  </Card>
</CardGroup>
