The registration link

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.

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:

  1. 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.
  2. It carries no credential of yours. Not the key, not the owner_id, not the external_ref, not an email — one opaque single-use token and nothing else.
  3. 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 a Referer header.

::: 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. :::

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_id here 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-link for it is 409 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 apps section 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. :::