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
| Scenario | Flow |
|---|---|
| User registered anonymously and wants to add email | client.identity.linkEmail() → OTP → verifyEmailLink() |
| User registered via SIWE and wants to add email for recovery | same as above |
| User registered via email/passkey and wants to add a wallet for Web3 features | client.identity.linkWallet() — see Wallet linking below |
| User wants to see all their identifiers (settings page) | client.identity.list() |
| User wants to remove an old email | client.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
Link an email
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 }, …]
Unlink an identifier
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
| HTTP | Code | Cause |
|---|---|---|
400 | INVALID_EMAIL | Email fails RFC 5322 validation. |
401 | SESSION_EXPIRED | No active session. Identity methods need one. |
403 | forbidden | Tried to modify another user’s identifiers. |
404 | IDENTIFIER_NOT_FOUND | identifier_id is unknown or already unlinked. |
409 | IDENTIFIER_ALREADY_LINKED | This email or wallet is already linked to another user in your tenant. |
410 | IDENTIFIER_OTP_EXPIRED | OTP past expires_at (10 min default). Start again. |
422 | IDENTIFIER_LAST_REMAINING | Anonymous user has only one identifier; cannot unlink. Link another first. |
429 | rate_limited | Exceeded per-email OTP cap (5/hour/destination). |
Webhook events
| Event | When emitted | Payload fields |
|---|---|---|
identifier.linked | An identifier is verified and linked. | user_id, identifier_id, type, value, linked_at |
identifier.unlinked | An 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.idmust equal the session’suserId. Attempts to modify another user’s identifiers return403 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.