Account Linking
Attach a wallet, an email login, or an X account to an existing Pear account, and sign in with X.
One Pear account can hold several ways to sign in. A user who registered with a wallet can add an email login, and a user who registered with email can add a wallet. Linking an X account also puts the trader on the leaderboard.
All linking calls need a first-party sign-in: send Authorization: Bearer <access-token>, or the session cookies. An API key or an OAuth grant is refused. See Authentication.
| Identity | Link | Remove |
|---|---|---|
| Wallet | POST /auth/link/wallet | — (not supported) |
| Email and password | POST /auth/link/emailpass | — (not supported) |
| X | POST /auth/link/x/start | DELETE /auth/link/x |
X is the only identity that can be unlinked. A wallet or an email login, once attached, stays attached.
Read what is linked
GET /auth/session/me is the source of truth for which logins an account holds:
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role": "basic",
"providerId": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
"loginMethod": "evm_wallet",
"identities": [
{
"provider": "evm_wallet",
"externalId": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
"connectedAt": "2026-01-01T00:00:00.000Z"
},
{
"provider": "x",
"externalId": "1234567890",
"connectedAt": "2026-01-02T00:00:00.000Z"
}
]
}providerId and loginMethod describe the current session. identities lists every login the account can be reached by, oldest first, which is what tells you whether to offer "connect" or "disconnect" for each one. provider uses the same values as loginMethod: evm_wallet, email, x, oauth.
externalId is the immutable id the provider knows the account by: the wallet address, the email address, or X's numeric user id. Mutable display data — the X handle, name, and avatar — is not here. Read that from GET /users/profile, which returns xHandle, xName, xAvatarUrl, xHidden, plus address, email, emailVerified, and displayName.
Link a wallet
Same nonce-and-signature exchange as a wallet login, but against the signed-in account:
POST /auth/noncewith the address, to get amessageto sign.- Sign it with the wallet.
POST /auth/link/walletwith the bearer token of the account you are linking to:
{
"address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
"signature": "0x…",
"nonce": "8f14e45f…"
}204 No Content means the wallet is attached and can now sign in to this account.
Link an email login
Send the address alone to POST /auth/link/emailpass. The password is set later, when the user opens the verification email:
{ "email": "trader@example.com" }204 No Content means the claim was accepted, not that the email is now usable. The gateway emails a verification token, and the address only becomes a login once the user redeems it:
{
"token": "<token from the email>",
"password": "a-new-password"
}Post that to POST /auth/verify-email. The password field is what makes the linked address a working login, so it is required for this flow. The response tells you which flow was verified:
{ "verified": true, "email": "trader@example.com", "flow": "link", "verifiedAt": "2026-01-01T00:00:00.000Z" }flow is link here, and register when the token came from POST /auth/register/emailpass.
204 does not always mean a new claim
The endpoint deliberately returns the same 204 for several outcomes, so it cannot be used to probe which addresses exist:
| Situation | What happens |
|---|---|
| Nobody owns the address | A claim is created and the email goes out. |
| This user already verified it | Nothing. The call is a no-op. |
| This user has a pending claim on it | The verification email is resent. |
| Another user has a pending claim on it | Nothing. The existing claim stands, and no email goes out. |
Only one case is an error: if the address is already verified by another account, the call returns 409.
Because a 204 may mean "resent" or "did nothing", drive your UI from a later GET /auth/session/me, not from the status code.
Resending the verification email
| Endpoint | Auth | Use when |
|---|---|---|
POST /auth/resend-verification/link | Bearer | The user is signed in and waiting on a link claim. Takes no body. |
POST /auth/resend-verification/email | None | The user is not signed in. Takes { "email": "…" }. |
When either one sends, it revokes the previous token and issues a new one, so the old link in the user's inbox stops working.
Both have a 60-second cooldown, and a throttled call is silent. If the live token is less than a minute old, the call returns 204 and does nothing: no revoke, no email. There is no header or body that tells you which happened, so put the cooldown in your own UI rather than reading it off the response.
The two differ on a missing claim. The unauthenticated variant always returns 204, whether or not the address exists, to prevent email enumeration. The bearer variant returns 404 No pending email claim found when the signed-in user has nothing pending.
Change a password
POST /auth/password/change takes the current password as proof:
{ "currentPassword": "…", "newPassword": "…" }Passwords are 8 to 128 characters. A successful change revokes every other session on the account and spares the caller's own, so a signed-in device elsewhere is signed out.
An account with no email identity gets 401 No email login method found for this account. Link an email login first. A wrong currentPassword also returns 401.
For a user who cannot sign in, use the reset flow instead: POST /auth/password/forgot → POST /auth/password/reset.
Link an X account
X linking is a browser redirect flow, so it cannot be done from a server.
- Start it from the frontend with
POST /auth/link/x/start, using the user's bearer token:
{ "returnTo": "https://your-app.example/settings" }It returns the URL to send the browser to:
{ "authorizeUrl": "https://x.com/i/oauth2/authorize?…" }returnTo is optional. Omit it and the flow returns the browser to the calling origin itself.
The request must carry an Origin that Pear allowlists, or the start call returns 400 before it even reads returnTo. Server-to-server calls therefore cannot start an X flow. returnTo must then be a page on that same origin; any other target is rejected at start time, rather than stranding the user after a full round trip through X.
If the deployment has no X app configured, every X endpoint returns 503.
-
Send the browser to
authorizeUrl. The user approves on X. -
X redirects to
GET /auth/link/x/callback, which finishes the link and redirects the browser back toreturnTo. Your page never calls this endpoint itself.
The callback takes no credential. The state parameter is the identity check: it is unguessable, single-use, expires in 10 minutes, and can only be minted by an authenticated start.
Reading the outcome
The callback always redirects, and never renders an error. Read the result from the query string on your return page:
https://your-app.example/settings?x_link=connected
https://your-app.example/settings?x_link=error&x_reason=already_registeredx_link | Meaning |
|---|---|
connected | The X account is now linked. |
reconnected | The same X account was already linked, and its handle, name, and avatar were refreshed. |
error | Nothing changed. Read x_reason. |
x_reason | Meaning |
|---|---|
denied | The user pressed cancel on X's consent screen. |
expired_state | The state was missing, already used, or older than its TTL. |
already_registered | That X account is linked to a different Pear account. |
different_account | This account already has a different X account linked. Disconnect that one first. |
x_unavailable | X failed, or X is not configured on this deployment. |
Render one message per reason. These strings are part of the contract.
Leaderboard visibility
A linked handle is published to other traders. PATCH /auth/link/x/visibility controls that without unlinking:
{ "xHidden": true }It echoes the state now in effect:
{ "xHidden": true }Three things make this easy to get right:
- It takes the wanted state, not a flip, so a retry lands on the same result.
- Hiding keeps the account linked, so signing in through X still works.
- The setting belongs to the user, not to the link. You can set it before linking, and it survives a disconnect and reconnect.
Disconnecting
DELETE /auth/link/x removes the link and stops the handle being shown. It returns 204. Anyone who could only sign in through X loses that route, so keep another identity on the account first.
It also revokes every other session that was signed in through X, sparing the caller's own. Sessions proved by a wallet or a password are left alone. This is the access half of the disconnect: the freed X account can be claimed by another Pear user straight away, so a surviving X session must not outlive the link.
Disconnecting when nothing is linked is a no-op, so a repeated call is safe.
Sign in with X
Once an X account is linked, it can also sign in. This flow never creates an account: if no Pear account has that X account connected, it fails with no_account.
The browser proves it is the same one that started the flow, using a challenge and verifier pair it mints itself.
- Before leaving, mint a random
handoffIdand a random verifier. Keep the verifier inlocalStorageon your own origin, and derive the challenge as the base64url SHA-256 of it, the same construction as PKCE.
The format is checked, not just the value. Both handoffChallenge and handoffVerifier must be exactly 43 base64url characters — A–Z, a–z, 0–9, -, _, no padding. That is what 32 random bytes and their SHA-256 both encode to:
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes); // 43 chars
const challenge = base64url(await crypto.subtle.digest('SHA-256', bytes)); // 43 charshandoffId is separate and looser: 1 to 64 URL-safe characters.
Then call POST /auth/login/x/start:
{
"returnTo": "https://your-app.example/login",
"handoffId": "login-2f8a1c",
"handoffChallenge": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}It returns the URL to send the browser to:
{ "authorizeUrl": "https://x.com/i/oauth2/authorize?…" }returnTo follows the same rules as the link flow: optional, defaulting to the calling origin, and restricted to a page on that origin. One difference here — any fragment you put on it is dropped, because the flow returns its own ticket in the fragment. A hash-routed page cannot use returnTo to name a route.
-
Send the browser to
authorizeUrl. -
X redirects to
GET /auth/login/x/callback, which resolves the account and redirects back toreturnTo:
https://your-app.example/login?x_login=signed_in&x_handoff=login-2f8a1c#x_ticket=…| Parameter | Where | Meaning |
|---|---|---|
x_login | query | signed_in or error. |
x_reason | query | On error: denied, expired_state, no_account, or x_unavailable. |
x_handoff | query | The handoffId you sent. It rides on every outcome, so a browser holding a verifier for a failed attempt knows which one to discard. |
x_ticket | fragment | Single-use, and valid for 60 seconds. |
The ticket is in the URL fragment, not the query, on purpose: a browser never sends a fragment to a server, so the ticket stays out of your access logs and out of the Referer of anything the page loads.
- Redeem the ticket with the verifier you kept, at
POST /auth/login/x/exchange:
{
"ticket": "<from the fragment>",
"handoffVerifier": "<the verifier from localStorage>"
}It returns the session:
{
"accessToken": "eyJhbGciOi…",
"refreshToken": "eyJhbGciOi…",
"user": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"role": "basic",
"providerId": "1234567890",
"loginMethod": "x"
}
}The response sets the session cookies and returns the token pair, so cookie and bearer clients are both served.
Two failures look different:
- A verifier that is not 43 base64url characters fails validation before anything is looked up:
400. - A well-formed verifier that does not match the challenge, or a spent or expired ticket:
401.
No session exists until this call succeeds. The callback mints no cookie, because a redirect has no response body and a cookie set there would be third-party storage for a frontend hosted on a different origin from the gateway.