An account you provisioned has no human attached to it. The registration link is how one gets
attached: your customer clicks a button in your product, an ordinary https:// page opens in their
own browser, and they type their own email and their own password.
That page is the only place in this feature that creates an identity record, and it does so with
the id you were already given. Nothing is migrated and nothing changes ownership — the owner_id
you stored on day one becomes the sub of their session token, byte for byte.
You never see their address. There is no email column on the account row and no partner route returns one. Before registration there is none; after it, it is theirs.
Mint the link
POST https://api-account.fluidvip.com/api/partner/accounts/{owner_id}/registration-link
The path takes the owner_id from provisioning, not your external_ref.
| Name | Type | Required | Description |
|---|---|---|---|
ttl_seconds |
integer | No | Lifetime override. Clamped server-side to [300, 86400]. Default 1800 (30 minutes) |
curl -X POST \
https://api-account.fluidvip.com/api/partner/accounts/9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218/registration-link \
-H "X-Partner-Key: fv_pk_…" \
-H "Content-Type: application/json" \
-d '{}'
201 Created:
{
"registration_url": "https://fluidvip.com/auth/register#t=8Kq2mR7vD1sTgW0xZaLpC5nJyH3bE6uYfO9iQrX4tVk",
"expires_at": "2026-08-25T10:11:17.004512+00:00"
}
The token in that URL appears here and nowhere else, ever. Only its sha256 is stored. There is no route that reads it back and no way to recover it. If you lose the URL, mint another.
Open it in the user's real browser
Call this endpoint from your server — your X-Partner-Key must never reach the machine your
customer is using — return just the registration_url to your client, and hand that URL to the
operating system's browser:
| Platform | Call |
|---|---|
| Electron | shell.openExternal(url) |
| Tauri | @tauri-apps/plugin-opener |
| .NET | Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }) |
| Web | window.open(url, "_blank", "noopener,noreferrer") |
Three properties of that URL explain why it is shaped the way it is, and all three constrain what you may do with it:
- It is an ordinary
https://link, not a custom scheme. A registered protocol handler would launch, but a browser is the only thing guaranteed to be present and the only thing that can run the bot challenge the page requires. - It carries no credential of yours. Not the key, not the
owner_id, not theexternal_ref, not an email — one opaque single-use token and nothing else. - The token is in the URL fragment (
#t=…), which browsers never send to a server. It cannot land in an access log, a proxy log, or aRefererheader.
::: warning Copy the URL whole, and do not email it.
The token is after the #. Anything that trims the fragment — a link shortener, a redirect through
your own domain, a chat client that "cleans" URLs — lands your customer on a dead page.
Emailing the link is not a supported channel. Mail security rewriters (SafeLinks and equivalents) mangle or drop fragments, so the link arrives broken. Hand it to the browser from your app. :::
One live link at a time
Minting replaces any outstanding token for that account. Re-issuing is the recovery path for an expired link; the old URL stops working the moment you mint a new one. That is deliberate — leaving old links alive would multiply the windows in which a leaked URL still works.
registration_link_outstanding on the account read tells you
whether one is currently live.
If your customer never clicks the button, nothing happens, indefinitely, and that is a supported steady state. The account keeps working through you and the token simply expires.
What your customer sees
The page pre-flights the token before asking for anything, so it can name you above the form:
GET https://api-account.fluidvip.com/api/register/{token}
{
"valid": true,
"partner_name": "Acme Ltd",
"expires_at": "2026-08-25T10:11:17.004512+00:00"
}
That is everything the pre-flight returns — your name, and nothing that identifies the account. No owner id, no external reference, no email. Enough to say "Finish setting up your Acme Ltd account" above two empty fields, and not enough to be worth harvesting.
They then submit:
POST https://api-account.fluidvip.com/api/register
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | From the URL fragment. 1-256 chars |
email |
string | Yes | Validated as an email address |
password |
string | Yes | 1-256 chars; strength is enforced upstream |
captcha_token |
string | No | The bot-challenge response. Max 4096 chars — but see below: without a configured challenge the route refuses |
full_name |
string | No | Max 120 chars |
marketing_opt_in |
boolean | No | Defaults to false |
200 OK:
{
"registered": true,
"owner_id": "9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218",
"email": "[email protected]",
"login_url": "https://fluidvip.com/login"
}
owner_idhere is byte-identical to the one you were given at provision time, and it is now their session's subject. That equality is the headline invariant of the whole design: nothing moved.
No session is minted by registration. They sign in normally at login_url, which is the one place
the password is actually proven to work — much better to find out on the next screen than on the next
visit.
::: warning The body forbids unknown fields.
There is no owner_id field on this request and there must never be one — the account is resolved
from the token, server-side. Sending an extra field is a 422, not a silent ignore. If you are
proxying this call, pass the body through unchanged rather than rebuilding it.
:::
Failure states, and what each one means
The token is consumed only by a successful registration. Every failure below leaves it live, so your customer can try again on the same link. That is the whole point of the table.
| Status | error |
What happened | Is the link still usable? |
|---|---|---|---|
409 |
email_in_use |
The most likely real-world failure. They already have a Fluid account on that address. Accounts are not merged, linked or transferred — the response carries login_url |
Yes. A different address on the same link works |
400 |
weak_password |
Rejected upstream. The response carries a reasons array so you can say which rule failed |
Yes |
400 |
captcha_failed |
The challenge did not verify | Yes |
404 |
registration_token_invalid |
Unknown, expired, or already used — one byte-identical body for all three, so a prober cannot tell which | No |
429 |
— | Over the per-IP registration cap | Yes, after Retry-After |
503 |
captcha_not_configured |
The environment has no challenge secret. Fails closed — an unconfigured captcha on a public account-creating endpoint is an open door, not a degraded feature | Yes, once configured |
503 |
provisioning_not_configured |
The plane is off in this environment | Yes, once enabled |
503 |
identity_not_configured |
The identity service is not configured here | Yes |
502 |
identity_upstream_failed |
The identity service refused or could not be reached. Carries upstream_code when there is one |
Yes |
500 |
identity_id_mismatch |
The identity service minted an id different from the one we allocated. The created user is deleted and the account left provisioned. This should never happen; report it | Yes |
The three cases behind registration_token_invalid are worth stating plainly, because your support
team will be asked about all of them:
- Expired — past
expires_at. Mint a new link. - Already used — a successful registration nulls the stored token. Permanent.
- Superseded — you minted a second link, which replaced the first.
After registration
The account row moves to state: "registered" with a registered_at, and:
POST …/registration-linkfor it is409 already_registered— permanently, by design. A partner who could re-mint a link for an account whose human already holds credentials could create a second identity for it.- Your account read reports the new state, so you can drive your own UI off it.
- Your customer now has a
Connected appssection in their settings naming you, with the date the connection started — and a button to sever it. See Limits → your customer can revoke you.
::: tip Poll the account read, not the link.
There is no webhook for "the customer registered". GET /api/partner/accounts/{owner_id} is free,
returns state and registered_at, and is the intended way to find out.
:::
Related
- Creating a customer account — where the
owner_idcomes from - Limits and failures — rate limits on both public routes
- The config panel — where the button that mints this link is meant to live