Hosted Onboarding
Build a platform that hosts other people’s apps? Hosted onboarding lets those platform operators (your customers’ admins) sign themselves up onto TryMellon without you embedding the WebAuthn ceremony in your own origin.
TryMellon spins up a hosted signup page at https://trymellon.com/signup/<sessionId>?return=<yourAppCallback>. The ceremony runs there (so the passkey RP ID is trymellon.com), and on completion the user is redirected back to your app. Exactly the pattern Stripe Connect, Auth0 Universal Login and Clerk use.
When to use this
| Scenario | Surface |
|---|---|
| End-user login in your app | client.signUp() / client.signIn() on @trymellon/js |
| A platform admin (your customer) needs a TryMellon account | createPlatform().createSignupLink() on @trymellon/js/platform ← this guide |
| Cross-device login with a QR | client.crossDevice.* (see Cross-device QR) |
Why a separate sub-path?
@trymellon/js/platformhas a different trust model — it issues signup links with no publishable key. We keep it off the main client so your end-user bundle doesn’t pay the cost, and your TypeScript autocomplete doesn’t leak a surface the end-user flow can’t use (enforced byclient.platform: never— see ADR-SDK-005).
Install
npm install @trymellon/js
No new package — the sub-path ships in the same npm release.
1. Issue a signup link
Flujo típico (polling, recomendado):
import { createPlatform } from '@trymellon/js/platform'
const platform = createPlatform({
apiBaseUrl: 'https://api.trymellonauth.com', // optional; this is the default
})
// SIN returnUrl. La hosted page redirige al dashboard de TryMellon al terminar;
// tu app se entera por `awaitSignupCompletion()` (paso 3).
const link = await platform.createSignupLink({
userRole: 'maintainer',
prefill: { companyName: 'ACME', email: 'founder@acme.com' }, // UX only, never trust
})
if (!link.ok) throw link.error
console.log(link.value.hostedUrl)
// → https://trymellon.com/signup/<sessionId>
Avanzado — returnUrl a tu propia app (requiere allowlist registrado):
const link = await platform.createSignupLink({
returnUrl: 'https://acme.com/onboarded', // pre-registrado en el allowlist del account
userRole: 'maintainer',
refreshUrl: 'https://acme.com/signup-expired', // optional fallback
})
Sin allowlist pre-registrado, pasar un
returnUrla tu propio origen fallaFORBIDDEN/invalid_return_url. Para first-signup el flujo de arriba (polling) es la respuesta correcta — el allowlist per-account se configura una vez que el account existe.
2. Hand the user the URL
You choose the delivery channel. Full-page redirect, popup, or a QR code for mobile → desktop handoff.
// Full-page redirect (desktop)
window.location.href = link.value.hostedUrl
// QR code (bring your own lib — we stay zero-dep)
import QRCode from 'qrcode'
const dataUrl = await QRCode.toDataURL(link.value.hostedUrl)
3. Wait for completion (optional)
If you want to react server-side the moment the ceremony finishes (e.g. provision resources in your app), poll the terminal state:
const controller = new AbortController()
setTimeout(() => controller.abort(), 5 * 60 * 1000) // 5 min max
const completion = await platform.awaitSignupCompletion(link.value.sessionId, {
signal: controller.signal,
intervalMs: 2_000, // default
})
if (!completion.ok) {
// 'ABORT_ERROR' | 'TIMEOUT' | 'SESSION_EXPIRED' | 'SERVER_ERROR'
return handleError(completion.error)
}
// The hosted page already redirected the user to returnUrl.
// Your backend can now provision tenant-side state.
If you only need a one-shot status check:
const status = await platform.getSignupStatus(link.value.sessionId)
// { status: 'pending_data' | 'pending_passkey' | 'completed' | 'expired' | 'failed', hostedUrl? }
Return URL allowlist
returnUrlmust be https.- For first-signup (no TryMellon account yet),
returnUrlis validated against a hardcoded path enum on the TryMellon landing origin. This is an anti-open-redirect safety net. - Once your account exists, the allowlist of accepted
return_url/refresh_urlvalues is stored per-account and editable from the dashboard. URLs are compared with string-exact matching — no path wildcards, no substring matches. - Invalid URLs fail fast with
TryMellonError.code === 'INVALID_ARGUMENT'before any HTTP call.
See ADR-076 §2.2 for the security rationale.
Error codes
| code | When |
|---|---|
INVALID_ARGUMENT | returnUrl is not a valid https URL. |
NETWORK_FAILURE | Unreachable API. |
FORBIDDEN | returnUrl passed validation client-side but the server allowlist rejected it. |
RATE_LIMIT_EXCEEDED | 2 signup links per hour per IP (AI agent safeguard). |
SESSION_EXPIRED | Session expired before the ceremony completed. |
ABORT_ERROR | AbortSignal fired during awaitSignupCompletion. |
TIMEOUT | maxAttempts exhausted without reaching a terminal state. |
Bundle size
@trymellon/js/platform is a separate tree-shaken bundle — ~2.92 KB gzip, no dependencies. If you only use it server-side (e.g. in a Next.js API route), it will never ship to the browser.
Related
- Getting started — end-user passkey login (
client.signUp/client.signIn). - API Reference — full SDK surface.
- Backend validation — validate session tokens issued after signup.