TryMellon
Navigation

Identity linking

Link email addresses and wallet addresses to a user after sign-up. Works with anonymous accounts, SIWE, and traditional flows — one user, many identifiers.

Identity linking

A user shouldn’t be forced to pick an identity at sign-up and stay there forever. Someone registers with a passkey, later wants to add an email for recovery. Or starts with Sign-In with Ethereum, later links a company email for team invitations. Or is anonymous today and adds a wallet tomorrow.

TryMellon models this directly: a user is a stable identity with zero or more verified identifiers attached. Identifiers can be emails, Ethereum wallet addresses, or custom values. Each is verified independently and linked atomically.

One user. Many identifiers. Any order.


When you’ll use it

ScenarioFlow
User registered anonymously and wants to add emailclient.identity.linkEmail() → OTP → verifyEmailLink()
User registered via SIWE and wants to add email for recoverysame as above
User registered via email/passkey and wants to add a wallet for Web3 featuresclient.identity.linkWallet() — see Wallet linking below
User wants to see all their identifiers (settings page)client.identity.list()
User wants to remove an old emailclient.identity.unlink(identifierId)

All identity methods require an authenticated session — the user must be signed in. userId is implicit from the session; you never pass it manually.


How it works

Authenticated user                    TryMellon API
       │                                    │
       │ start linking                      │
       │── POST /v1/users/:id/identifiers ─>│  store pending + dispatch OTP
       │<── { identifier_id, expires_at } ──│
       │                                    │
       │  user receives OTP (email channel) │
       │                                    │
       │ confirm                            │
       │── POST …/identifiers/verify ──────>│  verify OTP, mark verified
       │<── { id, type, value, verified }───│  emit `identifier.linked`
       │                                    │
       │  …later…                           │
       │                                    │
       │── GET …/identifiers ──────────────>│
       │<── [ { id, type, value, … } ]  ────│
       │                                    │
       │── DELETE …/identifiers/:id ───────>│  unlink + emit `identifier.unlinked`
       │<── 204 No Content ─────────────────│

The identifier_id returned from the start call is the handle you pass to verify — it lets the user confirm on a different device if needed (start on desktop, receive OTP on mobile, paste code back on desktop).


Quick start (SDK)

All examples assume a signed-in client configured with the web3 preset:

import { TryMellon } from '@trymellon/js'

const clientResult = TryMellon.create({
  appId: 'YOUR_APP_ID',
  publishableKey: 'cli_…',
  preset: 'web3',       // exposes client.identity and client.siwe
})
if (!clientResult.ok) throw clientResult.error
const client = clientResult.value
const start = await client.identity.linkEmail({ email: '[email protected]' })
if (!start.ok) return showError(start.error)

// User enters the 6-digit OTP received by email
const verify = await client.identity.verifyEmailLink({
  identifierId: start.value.identifierId,
  otp: '632145',
})
if (!verify.ok) return showError(verify.error)

console.log('Linked:', verify.value)
// { id: '…', type: 'email', value: '[email protected]', verified: true, linkedAt: '…' }

List linked identifiers

const result = await client.identity.list()
if (!result.ok) return
// [{ id, type: 'email'|'wallet'|'custom', value, verified, linkedAt }, …]
await client.identity.unlink({ identifierId: 'idf_…' })

Anonymous users cannot unlink their last identifier — you’d be stranding the account. The SDK surfaces this as IDENTIFIER_LAST_REMAINING.

Wallet linking

Linking a wallet is a two-step SIWE exchange:

// 1. Get a nonce bound to the user's session
const nonce = await client.siwe.getNonce()

// 2. Build the SIWE message and have the user sign with their wallet (wagmi, viem, etc.)
const message = client.siwe.prepareMessage({
  address: '0x1234…',
  chainId: 1,
  nonce: nonce.value,
})
const signature = await wallet.signMessage(message)

// 3. Verify — if a session is already active, TryMellon links the wallet to the current user
const linked = await client.siwe.verifyAndSignIn({ message, signature })

For users without an active session, the same call creates (or matches) a user instead — see SIWE login. The difference is decided server-side based on the request’s auth state.


API reference

All endpoints require a Bearer session token. Every use case enforces params.id === session.userId — you cannot modify someone else’s identifiers.

POST /v1/users/:id/identifiers

Start an email link. Dispatches a 6-digit OTP to the address.

  • Body: { email: string }

  • Returns — 201:

    {
      "ok": true,
      "data": {
        "identifier_id": "idf_01HXYZ…",
        "expires_at": "2026-04-17T10:10:00.000Z"
      }
    }

POST /v1/users/:id/identifiers/verify

Confirm the OTP. On success, the identifier is marked verified and identifier.linked is emitted.

  • Body: { identifier_id: string, otp: string (6 digits) }

  • Returns — 200:

    {
      "ok": true,
      "data": {
        "id": "idf_…",
        "type": "email",
        "value": "[email protected]",
        "verified": true,
        "linked_at": "2026-04-17T10:00:00.000Z"
      }
    }

GET /v1/users/:id/identifiers

Return all identifiers for the user, verified or pending.

DELETE /v1/users/:id/identifiers/:identifier_id

Unlink. Returns 204 No Content. Emits identifier.unlinked.


Error codes

HTTPCodeCause
400INVALID_EMAILEmail fails RFC 5322 validation.
401SESSION_EXPIREDNo active session. Identity methods need one.
403forbiddenTried to modify another user’s identifiers.
404IDENTIFIER_NOT_FOUNDidentifier_id is unknown or already unlinked.
409IDENTIFIER_ALREADY_LINKEDThis email or wallet is already linked to another user in your tenant.
410IDENTIFIER_OTP_EXPIREDOTP past expires_at (10 min default). Start again.
422IDENTIFIER_LAST_REMAININGAnonymous user has only one identifier; cannot unlink. Link another first.
429rate_limitedExceeded per-email OTP cap (5/hour/destination).

Webhook events

EventWhen emittedPayload fields
identifier.linkedAn identifier is verified and linked.user_id, identifier_id, type, value, linked_at
identifier.unlinkedAn identifier is removed.user_id, identifier_id, type, value, unlinked_at

Both are emitted to the application’s configured webhook URL. See Webhook events.


Security & privacy

  • Ownership check on every call. params.id must equal the session’s userId. Attempts to modify another user’s identifiers return 403 forbidden — tested at the route level.
  • Uniqueness per tenant. The same email or wallet can be linked to at most one user within a tenant. Cross-tenant duplicates are allowed by design (each tenant is an isolated directory).
  • Short OTP TTL. Link OTPs live for 10 minutes and are atomically consumed on first successful verify — no replay.
  • Rate limited per destination. Max 5 link-email OTPs per hour per email address, to prevent using TryMellon as a spam amplifier.
  • No PII at sign-up. Combined with anonymous signup, linking lets users onboard with zero personal data and add identifiers progressively as trust grows.