> For the complete documentation index, see [llms.txt](https://titan-exchange.gitbook.io/titan/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://titan-exchange.gitbook.io/titan/developer-doc/special-order-types/guides/onboarding.md).

# Onboarding (SIWS)

Bind a user's external wallet to a Titan-managed manager with one SIWS signature.

Every user makes exactly one signature before their first order, whatever its type. `POST /partner/onboard` takes that Sign-In-with-Solana signature, provisions the user's Titan-managed manager, and records their external wallet as the funding and withdrawal address. After this, you act on the user's behalf with the `X-Titan-User` header alone — no further per-request signatures.

The call is **idempotent and resumable**. Retrying with the same `sub` replays the same `userId` and `walletAddress`, so it's safe to call on every login if you'd rather not track who's already onboarded.

## What it provisions

A single call resolves or creates the user's Titan identity (namespaced to your tenant), creates their **manager** with the signing policy baked in, and binds their external wallet to that identity. The policy pins the manager to order-execution swaps — DCA cycles and trigger fills — plus withdrawals **only** to the user's own external wallet, so neither you nor Titan can move funds anywhere else.

## Build the canonical message

The user signs these exact bytes with their external wallet. `Address:` must equal `userPubkey`; `User:` must equal the `sub` you send in the body.

```
Titan DCA wants you to link this Solana wallet.

Address: <userPubkey — base58>
User: <your stable user id>
Issued At: <ISO-8601, within ±10 min of now>
Nonce: <opaque random string>
```

```typescript
function buildSiwsMessage(address: string, sub: string): string {
  return (
    `Titan DCA wants you to link this Solana wallet.\n\n` +
    `Address: ${address}\n` +
    `User: ${sub}\n` +
    `Issued At: ${new Date().toISOString()}\n` +
    `Nonce: ${crypto.randomUUID()}\n` // trailing newline is required
  );
}
```

{% hint style="warning" %}
The signed bytes must match the canonical form exactly: LF line endings, a trailing `\n` after `Nonce:`, and ≤ 1024 bytes. `Issued At` must be within ±10 minutes of server time. A mismatch returns `400 SIWS_INVALID`.
{% endhint %}

The user's wallet signs the message bytes in your frontend; you forward the base58 signature to your backend:

```typescript
const message = buildSiwsMessage(userPubkey, sub);
// const { signature } = await wallet.signMessage(new TextEncoder().encode(message));
// const signatureBase58 = bs58.encode(signature);
```

## Submit it

```typescript
const res = await fetch(`${process.env.TITAN_DCA_BASE_URL}/partner/onboard`, {
  method: 'POST',
  headers: {
    'X-Titan-Key': process.env.TITAN_DCA_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    sub,
    userPubkey,
    siws: { message, signature: signatureBase58 },
  }),
});

const { data } = await res.json();
// { userId: "<opaque Titan user id>", walletAddress: "<manager pubkey>" }
```

This call uses `X-Titan-Key` only — there's no `X-Titan-User` yet, because this is the call that creates the mapping.

Store `userId`. It's the opaque Titan id you pass to the [partner reporting](/titan/developer-doc/special-order-types/reference/endpoints.md#partner-reporting) filters (`?userId=…`) — it is **not** your `sub`. From here on, identify the user on every call with `X-Titan-User: <sub>`.

## Replay and freshness

Titan enforces the ±10-minute `Issued At` window plus the signature and ownership checks. The `Nonce` is opaque and not persisted, so it isn't checked for single use — a message can be re-submitted within its freshness window. That's safe because onboard is idempotent: a replay just returns the same `userId` / `walletAddress`. Generate a fresh `Issued At` and nonce per attempt anyway.

## Single-transaction onboarding

The flow above asks the user for two signatures before their first order: the SIWS message here, then the deposit transaction. For a brand-new wallet you can collapse that to one. Pass `onboardIfNeeded: true` on [`POST /orders/intent`](/titan/developer-doc/special-order-types/guides/creating-orders.md#get-an-unsigned-deposit-transaction), and if the `X-Titan-User` id was never onboarded, Titan provisions the manager inline and returns the deposit transaction as usual. That deposit is signed by `userPubkey`, so the signature doubles as the ownership proof — a wrong or unowned address can never fund the order, and until it's signed the manager is an empty wallet whose policy only permits outflows back to `userPubkey`.

The flag is an explicit opt-in: it must be exactly `true`, and it only provisions **new** users. For an already-onboarded user it's ignored (the intent behaves as normal, including `403 VALIDATION_ERROR` if `userPubkey` doesn't match the attested wallet). It never links an *additional* wallet to an existing user — that still needs the SIWS flow.

{% hint style="danger" %}
**Build the two-step fallback before you ship the one-shot path.** A wallet that's brand-new to *you* can still be known to *Titan* — the user may have used it on the Titan app or through another partner, and wallets are recognized across the whole platform, not just your tenant. When that happens, `onboardIfNeeded: true` returns `409 USER_PUBKEY_CONFLICT` instead of silently attaching you to that account. You must detect this `409` and fall back to the two-step SIWS flow, or those users can't place their first order — and it can happen the very first time you see a user.
{% endhint %}

Handling the `409`:

{% stepper %}
{% step %}

### The one-shot intent came back `409`

`POST /orders/intent` with `onboardIfNeeded: true` returned `409 USER_PUBKEY_CONFLICT` — the wallet already belongs to a Titan account (yours, the Titan app's, or another partner's).
{% endstep %}

{% step %}

### Onboard with SIWS

Collect one SIWS signature and call `POST /partner/onboard` as above. This proves the user owns the wallet and links your `X-Titan-User` id to the existing account. Idempotent and safe to retry.
{% endstep %}

{% step %}

### Re-issue the intent

Call `POST /orders/intent` again **without** `onboardIfNeeded` (the user is now onboarded), then `POST /orders/confirm` as normal.
{% endstep %}
{% endstepper %}

A brand-new wallet is one signature; a wallet already known to Titan is the usual two. New users — the common case — never hit the `409`. Still use `POST /partner/onboard` directly when you want to bind the wallet ahead of any deposit, or you need the `userId` for reporting before the first order.

## Errors

| HTTP      | `error.code`                                       | When                                                                                                                                                       |
| --------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400       | `BAD_REQUEST`                                      | Missing `sub` / `userPubkey` / `siws.message` / `siws.signature`, or invalid JSON.                                                                         |
| 400       | `SIWS_INVALID`                                     | Signature, canonical-form, freshness, or ownership check failed — bad signature, `Address` ≠ `userPubkey`, `User` ≠ `sub`, or `Issued At` outside ±10 min. |
| 400       | `PARTNER_NOT_CONFIGURED`                           | Your tenant isn't enabled for partner onboarding.                                                                                                          |
| 401       | `INVALID_API_KEY` / `KEY_REVOKED` / `ENV_MISMATCH` | Standard `X-Titan-Key` failures.                                                                                                                           |
| 409       | `USER_PUBKEY_CONFLICT`                             | Your `sub` and the attested wallet resolve to two different existing Titan identities.                                                                     |
| 409       | `WALLET_NEEDS_USER_CONSENT`                        | The wallet exists with no manager setup and Titan can't attach one server-side (rare edge).                                                                |
| 500 / 502 | `PROVISIONING_FAILED`                              | Provisioning error (`502` upstream, `500` unexpected). Safe to retry — the call is idempotent.                                                             |

A user-scoped call against a not-fully-provisioned manager returns `409 ONBOARDING_INCOMPLETE`. Re-call `POST /partner/onboard` (idempotent), then retry the original request.

## Related pages

* [Quickstart](/titan/developer-doc/special-order-types/quickstart.md) — onboarding in the context of the full flow
* [Authentication](/titan/developer-doc/special-order-types/reference/authentication.md) — how `X-Titan-User` resolves a user after onboarding
* [Creating Orders](/titan/developer-doc/special-order-types/guides/creating-orders.md) — the first thing you do once a user is onboarded
