> ## 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.

# Transfers

> Move a unit from its current owner to a new owner, authorized by a chip-tap session token. The current owner signs and submits the transfer — Endstate authorizes it, but never moves a unit on an owner's behalf.

A **transfer** moves a [unit](/concepts/units) from its current owner to a new owner. Like a [claim](/concepts/claims), it is authorized by a [session token](/concepts/session-tokens) from a recent tap of the unit's chip — an ownership change always traces back to physical possession of the item.

The difference is who executes it. A claim hands a unit to its first owner, and Endstate can submit that for you. A transfer moves a unit **between owners**, and only the current owner can complete it: the API returns a ready-to-send `transaction`, and the owner submits it from their own wallet. Endstate authorizes the transfer but never moves a unit on an owner's behalf.

## How transferring works

<Steps>
  <Step title="Verify a tap">
    The user taps the item's chip; your server verifies it (`POST /v1/chips/     {chip_id}`) and receives a session token scoped to that chip and unit.
  </Step>

  <Step title="Create the transfer">
    Call `POST /v1/units/{unit_id}/transfers` with the recipient's `to` address,
    authorized by the session token. Endstate signs the transfer authorization
    and returns a `transaction` payload.
  </Step>

  <Step title="The current owner submits">
    The unit's current owner sends the `transaction` from their wallet, on the
    collection's network. A transaction sent by any other account is rejected.
  </Step>

  <Step title="Poll for status">
    Poll `GET /v1/units/{unit_id}/transfers/{transfer_id}` until `status` is
    `confirmed`.
  </Step>
</Steps>

## Authorization

`POST /v1/units/{unit_id}/transfers` **requires a session token** (`end_sess_...`) from a tap of the same unit's chip — an API key alone cannot create a transfer.

`GET /v1/units/{unit_id}/transfers/{transfer_id}` accepts **either** an API key or a session token, so your backend or your client can poll for status.

## Create a transfer

`POST /v1/units/{unit_id}/transfers`

<ParamField body="to" type="string" required>
  The address that will receive the unit (`0x` followed by 40 hex characters).
</ParamField>

```bash theme={null}
curl -X POST https://api2.endstate.io/v1/units/22222222-2222-4222-8222-222222222222/transfers \
  -H "Authorization: Bearer end_sess_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "0x1111111111111111111111111111111111111111"
  }'
```

```json theme={null}
{
  "id": "00000000-0000-4000-8000-000000000000",
  "unit_id": "22222222-2222-4222-8222-222222222222",
  "to": "0x1111111111111111111111111111111111111111",
  "from": "0x2222222222222222222222222222222222222222",
  "status": "prepared",
  "transaction": {
    "to": "0x3333333333333333333333333333333333333333",
    "data": "0xa9059cbb"
  },
  "created_at": "2026-06-15T12:00:00.000Z"
}
```

The `transaction` payload is returned when the transfer is created. Hand it to the unit's current owner (the `from` address) to submit.

## Submitting the transaction

Unlike a claim's `client_broadcast` submission — which any funded account can send — a transfer **must be sent by the current owner**: the transaction is rejected unless the sender is the `from` address. The owner needs gas on the collection's network.

```ts theme={null}
import { createWalletClient, http } from "viem";

// walletClient must be the unit's CURRENT owner (transfer.from) —
// a transaction from any other account is rejected.
const hash = await walletClient.sendTransaction({
  to: transfer.transaction.to,
  data: transfer.transaction.data,
});
```

Use any EVM wallet client or signer — see the [viem](https://viem.sh) or [ethers](https://docs.ethers.org) docs. In a user-facing flow this is typically the owner confirming in their connected or embedded wallet.

The signed authorization is valid for **30 minutes** from transfer creation — a transfer that is not submitted and confirmed in that window becomes `expired`.

## Transfer status

`GET /v1/units/{unit_id}/transfers/{transfer_id}` — poll this until the transfer settles.

| `status`    | Meaning                                                                                          |
| ----------- | ------------------------------------------------------------------------------------------------ |
| `prepared`  | Authorized and waiting for the current owner to submit the `transaction`. Keep polling.          |
| `confirmed` | Ownership moved and confirmed. Terminal.                                                         |
| `expired`   | The authorization's 30-minute window passed before it settled. Terminal — create a new transfer. |
| `failed`    | The transaction failed or was dropped. Terminal — create a new transfer.                         |

## The transfer object

<ResponseField name="id" type="string (UUID)">
  The transfer's unique identifier.
</ResponseField>

<ResponseField name="unit_id" type="string (UUID)">
  The unit being transferred.
</ResponseField>

<ResponseField name="to" type="string">
  The recipient address.
</ResponseField>

<ResponseField name="from" type="string">
  The current owner's address — the only account that can submit the
  `transaction`.
</ResponseField>

<ResponseField name="status" type="string">
  One of `prepared`, `confirmed`, `expired`, `failed`.
</ResponseField>

<ResponseField name="transaction" type="object | null">
  The payload the current owner submits — `to` (the collection's contract
  address) and `data` (the encoded transfer call). Returned when the transfer is
  created.
</ResponseField>

<ResponseField name="created_at" type="string (ISO-8601)">
  When the transfer was created.
</ResponseField>

## Errors

| Code                               | HTTP | When                                                                                                      |
| ---------------------------------- | ---- | --------------------------------------------------------------------------------------------------------- |
| `transfer.in_progress`             | 409  | A transfer is already open for this unit. Wait for it to settle before starting another.                  |
| `transfer.already_to_recipient`    | 409  | The unit is already held by that address.                                                                 |
| `transfer.owner_unknown`           | 409  | The unit's current owner cannot be determined yet. Retry shortly.                                         |
| `transfer.not_found`               | 404  | No transfer with that ID exists for the unit.                                                             |
| `unit.not_minted`                  | 409  | The unit has not been issued yet — pair a chip and wait for `token.status: "active"` before transferring. |
| `session_token.wrong_chip`         | 403  | The session token is bound to a different unit than the one in the path.                                  |
| `session_token.invalid_or_expired` | 401  | The session token is unknown or has expired — re-verify a tap.                                            |

Branch on `error.code`, not the HTTP status. See [Errors](/conventions/errors) for the full envelope.

## Next steps

<CardGroup cols={2}>
  <Card title="Claims" icon="arrow-right-left" href="/concepts/claims">
    Hand a unit to its first owner — Endstate can submit that one for you.
  </Card>

  <Card title="Session tokens" icon="key" href="/concepts/session-tokens">
    How a tap produces the session token that authorizes a transfer.
  </Card>
</CardGroup>
