# Integrate copy trading — the complete instructions in one file

This file is self-contained on purpose: paste it into your AI coding agent (Claude
Code, Cursor, …) or read it yourself, and you have everything needed to ship a working
integration. Machine-checkable reference: the API publishes OpenAPI at `/docs`
(JSON at `/docs/openapi.json`), generated from the same schemas that validate requests.

## What you are integrating

Copy trading for your existing users: they pick a graded leader, set risk limits, sign
once (perps) or send one transaction (spot), and the backend mirrors the leader's
trades within those limits. Execution credentials are scoped to trading only — for the
scoped venues (Hyperliquid perps, spot smart sessions, Polymarket predictions) the
backend can trade but can never withdraw, and the user can revoke at any time.

Do not claim "non-custodial" in UI from the server's own flags. Gate it client-side:
`assertCustodySafe(venueId)` or `venue.custodyTrust === 'client-static'`. A compromised
API can lie about itself; the SDK's static custody table cannot.

## Setup

```bash
npm install copytrading
```

Two credentials, because two parties are involved:

| Credential | Header | Identifies | You get it from |
|---|---|---|---|
| Partner API key | `Authorization: Bearer key_…` | your app (billing, entitlements) | **self-serve**: sign in at https://www.unhosted.ai/partners → "Create your sandbox org" → Keys → mint. Sandbox keys start `key_test_`; go live (Settings → Go live, owner accepts the terms) and mint `key_live_` keys yourself |
| Wallet signature | `X-Wallet-Signature` + `X-Wallet-Address` + `X-Auth-Timestamp` | the end user whose funds move | generated per request by `createWalletAuthHeaders` |

Rules that prevent the two most common 401s:

- **Never hand-roll the signed message.** It is an EIP-191 `personal_sign` over a
  canonical string naming the HTTP method and path; it expires after 5 minutes. Use
  `createWalletAuthHeaders`.
- **The address must be EIP-55 checksummed.** The server checksums before rebuilding
  the message, so a lowercase address signs a different string and returns a bare 401.
- Build the auth-headers helper **once** and reuse it — it holds a monotonic timestamp
  counter that prevents replay rejections.

Sandbox is fully self-serve: create your org at https://www.unhosted.ai/partners
(wallet or Google/email — no approval step) and mint `key_test_` keys yourself.
Sandbox agents run in paper mode — real leader data through real gates, executions
recorded as `skipped/paper_mode`, structurally unable to reach mainnet. Note: paper
mode reproduces gating, direction and notional — not isolated-margin or open-order
reconciliation. Do not calibrate sizing against it.

Going live is self-serve too: the org **owner** opens Settings → **Go live** in the
console, reads the pricing and terms, and accepts. The moment the plan flips, **every
`key_test_` key is revoked** (expect 401s until you swap in a key) — mint `key_live_`
keys from the Keys panel and redeploy with those. Agents created in the sandbox stay
in paper mode forever; create new agents to trade live. The flip reaches the
execution service within about a minute.

## The integration — five calls

```ts
import { CopyTradingClient, createWalletAuthHeaders, assertCustodySafe } from 'copytrading';

const client = new CopyTradingClient({
  baseUrl: API_URL,
  apiKey: PARTNER_KEY,
  getAuthHeaders: createWalletAuthHeaders({ address: userAddress, signer: personalSigner }),
});

// 1. What can you offer? Entitlements are per-partner — ask, don't assume.
const venues = await client.listVenues();

// 2. Who is worth copying? Graded for copyability, not ranked by PnL.
//    Clean by default — only leaders that pass the gates. Pass
//    { mirrorableOnly: false } only for a "check any wallet" lookup surface,
//    where the gated-out entries and their assessment.reasons are the answer.
const leaders = await client.listLeaders({ venue: 'hyperliquid-perps' });

// 3. Create the agent with the user's risk limits.
//    masterAddress = the EOA behind your wallet-auth signer. When the smart account
//    and the signing EOA differ (every smart-account wallet), omitting it means every
//    later owner-scoped call — including activation — 403s.
const { agentId } = await client.createAgent({
  venue: 'hyperliquid-perps',
  smartAccountAddress: userAddress,
  masterAddress: signerAddress,
  leaderWalletAddress: leaders[0].address,
  riskConfig: { maxUsd: '500', copyPercent: 10 },
});

// 4. One call, every venue — walks whatever signing steps that venue needs.
await client.activate(agentId, signer);

// 5. Same execution feed shape everywhere.
const { executions } = await client.listExecutions(agentId);
```

`signer` needs `signTypedData` (all venues); `signMessage` only for Polymarket;
`sendTransaction` only for spot. A venue that needs a method you did not provide fails
immediately with a message naming it.

Venues:

| id | kind | activation | credentials expire | sandbox |
|---|---|---|---|---|
| `hyperliquid-perps` | perps | 1–2 signatures, no gas, any EOA | never | paper mode |
| `spot` | spot | one on-chain tx (ERC-7579 smart account required) | at session expiry | paper mode |
| `polymarket-predictions` | prediction | 1 signature | you set it | not yet deployed |

### What your stack needs, per venue

Partner-side auth is identical everywhere; venues differ in what the **end user's
wallet** must be able to do. This is the deciding question for which venues you can
ship with the wallet stack you already have:

| venue | end-user wallet | on-chain actions | funding |
|---|---|---|---|
| `hyperliquid-perps` | **any EOA that signs EIP-712** — browser extension, embedded (Web3Auth / Privy / Magic), MPC | none — signatures only, no gas, no contracts | USDC deposited on Hyperliquid |
| `spot` | **ERC-7579 smart account** (e.g. Kernel v3+) with a sessions module, plus 4337 infra to submit user ops | one session-enable operation at activation; copies execute as user ops from the account | tokens to trade + gas (or a paymaster) on Base |
| `polymarket-predictions` | ERC-7579 smart account **deployed on Polygon** (not yet enabled — check `availability` at discovery) | one-time account deploy + session install | USDC.e on Polygon |

If your app has no smart-account infrastructure, start — and possibly stay — with
`hyperliquid-perps`: it is deliberately the lowest integration bar, and most apps can
ship it with the wallets they already support. Spot and predictions require your
users to have smart accounts. A plain-EOA integration that requests a smart-account
venue is stopped at the exact activation step it cannot perform, with the
prerequisite named (`client_session_enable` / `client_account_deploy`) — nothing
fails cryptically, but it cannot be worked around client-side.

**Start with `hyperliquid-perps`** — no smart-account infrastructure, one signature,
copy is live. A `key_test_` key gets both perps and spot in **paper mode**: activation
is real (the venue handshake, the on-chain session), the leader is watched for real,
and every mirror the agent *would* have placed is recorded as
`Execution{status:'skipped', skipReason:'paper_mode'}` — nothing is submitted, so do
not calibrate sizing against paper output.

## Build by screen — the recipe map

You are building screens, not endpoints. The Intelligence API (same base URL under
`/api/v1`, same partner key) supplies the judgement layer; each screen maps to a
short list of calls. Full recipes with response fields and UX rules:
https://docs.unhosted.com/integrate (raw markdown at /llm/integrate).

| Screen | Calls |
|---|---|
| Leaderboard / "who to copy" | `GET /v1/trader-board` (discovered proven wallets) · `POST /v1/trader-leaderboard` (rank wallets you supply) · `GET /v1/copy/strategies` (venue leaders, graded) |
| Trader page | `POST /v1/wallet-overview` — the whole nine-read dossier (score, copy-sim, edge, PnL quality, archetype, style, health, netflow, trades) in ONE request; slots named after the single endpoints they mirror. Perp side: `POST /v1/perp-wallet` (renders `coverage.note` when thin) |
| Portfolio page | `POST /v1/portfolio-overview` — aggregate holdings risk + drain exposure + posture in one request |
| Token page | `POST /v1/token-overview` — the ten-read token dossier (profile, signal, smart money, exit risk, inflection, holders, valuation, narrative, deltas, price structure) in ONE request; singles à la carte |
| Copy flow | vet with `wallet-overview` / `perp-wallet`, size with `POST /v1/copy-plan` / `perp-copy-decision`, then the five SDK calls below |

Bundles fan out server-side through the same handlers as the singles, so a bundle
slot is byte-for-byte the single endpoint's response — start with the bundle, go
à la carte only when you render a subset. Intelligence access is included with
your partner key — no per-call bill; the `X-Usage-Units` header (bundles = sum of
parts) drives fair-use rate limits and lets you see what your integration consumes.

The whole map also exists as running, CI-typechecked code:
`examples/intel-screens.ts`, shipped in this package — one function per screen
returning exactly the fields that screen renders, triggers in comments. Wire it
to your components and redesign the presentation.

## UX rules that make or break the product

1. **Render the leader `assessment.reasons`.** `listLeaders` is the leaderboard
   *after* copyability gates (on one live venue screen, 2 of the top 12 by PnL
   survived — the rest were market makers a follower can't mirror). The plain-language
   reasons are the product. `assessment.evidence` says whether the verdict rests on
   real trade history or the venue's own figures.
2. **Show skipped executions.** `status: 'skipped'` with a machine-readable
   `skipReason` (`slippage_exceeded`, `edge_exhausted`, `too_close_to_resolution`, …)
   means the system refused a trade that would have been worse than not trading. A
   silent gap in the feed reads as a bug to users; a labelled skip reads as protection.
3. **Handle `expired` separately from `paused`.** Expired credentials (Polymarket,
   spot sessions) need the user to re-run `activate()` — a resume button will not work.
   Subscribe to the `agent.session_expiring` webhook.

## Managing a live copy

```ts
await api.getAgent(agentId);
await api.listExecutions(agentId, { limit: 50 });         // page with { before: nextBefore }
await api.updateRiskConfig(agentId, { maxUsd: '250' });   // no re-activation needed
await api.pauseAgent(agentId);
await api.resumeAgent(agentId);
```

## Webhooks (recommended over polling)

Partner-level: registered with your API key alone, one endpoint covers every agent.

```http
POST /v1/agent/webhooks
{ "url": "https://you.example.com/hooks/copytrade",
  "events": ["execution.confirmed", "execution.failed"] }   // omit for all
```

The response contains `secret` — shown exactly once. Events: `execution.submitted`,
`execution.confirmed`, `execution.failed`, `execution.skipped`,
`agent.session_expiring`.

Verification rules:

- Verify against the **raw** request body — re-serializing parsed JSON changes the
  bytes and breaks the signature. In Next.js pages API: `export const config =
  { api: { bodyParser: false } }`.
- Use `parseWebhookPayload({ secret, body: rawBody, header:
  req.headers['x-unhosted-signature'] })`; on throw, respond 400 and do nothing else.
- Delivery is at-least-once: **dedupe on `event.id`** before side effects.
- Non-2xx deliveries retry at 1m, 5m, 30m, 2h, 6h, then drop. Inspect with
  `GET /v1/agent/webhooks/:id/deliveries`.

## Spot (only if you run ERC-7579 smart accounts)

Spot installs a scoped, expiring smart session that can call swap routers (LiFi,
1inch, Uniswap Universal Router, 0x AllowanceHolder) and nothing else — it cannot
transfer tokens out. You implement a `SpotSessionAdapter` and `TransactionSender`;
start from `examples/spot-session-adapter.ts` (~200 lines, CI-typechecked). The three
mistakes every first spot integration makes:

1. Enabling a session on an account without the SmartSessions validator installed —
   it reverts. Run `installSmartSessionsModule` once first.
2. `JSON.stringify` throwing on the session object — it contains bigints; serialize
   before returning it to the API.
3. On Kernel v3.3 only: not granting the execute selector — the session enables fine
   and then every copied trade reverts. Use the
   `allowKernelExecuteForSmartSessions` hook; Safe7579 and Nexus skip this entirely.

There is a fourth, one level up: forgetting `masterAddress` on `createAgent`. The
smart account cannot produce the EOA signature the wallet auth requires, so the
signer is a different address — and without `masterAddress` naming it, the agent you
just created 403s every call you make about it, including `activate`.

Sandbox spot agents run the same activation for real — the session is enabled
on-chain on the venue's mainnet chain (Base by default, a few cents of gas) and
verified by the server — then execute in paper mode: real leader swaps observed,
real gates, recorded `paper_mode` executions, nothing submitted and no session key
ever used. Your executions screen, webhooks and skip-reason handling exercise
end to end before any real funds move.

## Errors

Every failure is a `CopyTradingError` with `status`, the API's `message`, and raw
`details`. Requests time out after 30s with `code: 'timeout'` instead of hanging.

Quick diagnosis table:

| Symptom | Cause |
|---|---|
| bare 401 on owner-scoped routes | lowercase (non-checksummed) address, or hand-rolled auth message |
| 401 after working earlier | wallet-signature older than 5 min, or timestamp replay — reuse one `createWalletAuthHeaders` instance |
| 403 with venue in message | your key is not entitled to that venue — ask your account contact |
| 403 "Wallet is not the owner" after a 201 create | `masterAddress` was omitted and your auth signer differs from `smartAccountAddress` |
| 400 naming supported chain ids | venue-first create with a `chainId` the venue does not run on |
| `{ signature }` rejected on approve | send the full `{ venue, agentAddress, approvals[] }` shape |
| spot trades revert on Kernel | missing execute-selector grant (see above) |
| webhook signature never verifies | body was parsed and re-serialized — verify the raw bytes |

## Definition of done

- [ ] `listVenues` renders only venues your key is entitled to
- [ ] Leader list shows `assessment.reasons`, not just PnL
- [ ] Agent created + activated on `hyperliquid-perps` with a sandbox key
- [ ] Spot agent activated with a sandbox key (session enabled on-chain) and
      `paper_mode` executions rendering in your feed
- [ ] Execution feed renders `skipped` rows with their `skipReason`
- [ ] Webhook receiver verifies raw-body signature and dedupes on `event.id`
- [ ] `expired` agents route users to re-activate, not resume
