Skip to main content
In hosted mode UserKit stores the password and owns the account. You build the forms; these endpoints are the rules behind them. Every endpoint here is public surface reached with a publishable key in the request body, from your own domain. The posture is the strictest in the API: non-enumerable responses, constant-time password paths, strict rate limits, and one-time tokens for anything that arrives by email.
These endpoints require the environment to be in hosted mode. On a federated environment they answer 409 environment_not_hosted.

Sign up

202 — always
Always 202. The response never reveals whether the address already has an account. Three different things happen behind that one answer:
1

A new address

The contact is created with the password attached — there is nothing here to take over — and a verification email goes out. email_verified stays false until the link is clicked.
2

An address that already has a password

Nothing is created. A “you already have an account” notice goes to the inbox, never to the caller.
3

A contact your server minted, with no password yet

The chosen password waits on the verification token and attaches only when the link proves the email.
That third case is the one worth understanding. If the password attached immediately, anyone could sign up with the address of a contact you pre-created and capture the account. Making the password wait on the token closes that structurally, not with a check somebody could forget.
Passing anonymous_id folds the visitor this person already was into the new contact, attribution included. Rate limited to 10 per hour per IP.

Verify the email

The link in the email carries a uk_cv_… token, valid 24 hours. Your page reads it from the query string and posts it:
200
Three things happen at once: the email is proven, any password waiting on the token attaches, and a verified session comes back — the person clicked from their inbox, do not make them sign in again.

Resend

202 always. Nothing goes out for unknown addresses or already-verified ones. Rate limited to 5 per hour per IP — each request mails somebody’s inbox.

Sign in

200
Unknown email, a contact with no password, and a wrong password all produce the same 401 invalid_credentials — and take roughly the same time, because the no-password path runs a throwaway bcrypt compare. Without that, only real accounts would pay the tens of milliseconds, and latency alone would answer “does this address exist”. Proving the password proves the account, so the session is verified. Rate limited to 20 per 5 minutes per IP.

Recover a password

1

Ask

Unconditional 202. Rate limited to 5 per hour per IP.
2

Reset

204. Rate limited to 10 per hour per IP.
The reset does three things beyond setting the password:
  • every session of that contact is revoked, immediately — a reset usually means somebody else may hold the credential;
  • the email counts as proven, because the link arrived in it;
  • a contact that was never identified becomes identified.
This flow also works for contacts that never had a password — the ones your server created with POST /v1/contacts. Receiving the link proves the email, so “reset” doubles as “set a password”.
Passwordless sign-in, and it works in both modes. In federated mode it is also the only thing that can upgrade an email from attribute to proven identity.
1

Request

Unconditional 202. Valid one hour. Rate limited to 5 per hour per IP.
2

Redeem

Answers the contact and a verified uk_ct_… session.
The redeem is atomic in SQL, so the link works exactly once. Used, expired and never-existed all answer the same 401 — three different truths, one response, on purpose.

Email codes

The same flow presented as six digits, and it works in both modes too. Reach for it instead of a link when the code will be typed on the device that asked for it: a code survives a mail client that opens links in its own browser, and it works when the inbox is on a phone and the sign-in is on a laptop.
1

Request

202 — always
Hold the challenge in the page. It is not a session and can never be used as one. Valid ten minutes. Rate limited to 5 per hour per IP.
2

Verify

Answers the contact and a verified uk_ct_… session. The code arrived in the inbox, so it proves the address exactly like the link does.
The challenge comes back for every address, whether or not it has an account — otherwise this endpoint would be the place somebody learns which addresses do. The verify holds the same line: a wrong code, an expired one, a spent one, and a challenge that never had a code behind it all answer the same 401 invalid_code.
Two rules make six digits safe to accept, and both matter:
  • Each code carries five attempts. A guess costs one whether or not it was right, and the code dies when they run out. Six digits is a million, which a rate limit alone does not close.
  • Asking for a new code spends the outstanding one. Otherwise every request would add another five guesses against another number.
The code alone is not enough either: it is only valid against the challenge it was issued for, so a code read over somebody’s shoulder opens nothing without the browser that asked for it.

Password policy

At least 8 characters, with a lowercase letter, an uppercase letter and a digit. Anything shorter or simpler answers 400 weak_password. Deliberately modest: length is what matters, and a policy people fight produces Password1! everywhere. And the password must not be a known-breached one. Every password set here — sign-up, reset, “set a password” — is checked against a public corpus of passwords that have appeared in breaches, and a match answers 400 breached_password. That single rule stops more account takeovers than any composition requirement, because credential stuffing is what actually happens to your customers.
The password does not leave our process, and neither does anything that identifies it. It is hashed locally and only the first five characters of that hash are sent; the corpus answers with every candidate under that prefix and the comparison happens here. The service learns that somebody asked about one of several hundred passwords, and nothing else.The check fails open. If the corpus cannot be reached the password is allowed and we log it: refusing every sign-up because a third party is down would be an outage we imported into your product.

The session

Every successful flow here hands back a uk_ct_… contact session, valid 30 days, with verified: true. Use it for the contact’s own reads:
Signing out deletes the session — the next lookup simply matches nothing.