Documentation menu
SDK

Enrolling users

How your own user IDs get bound to real devices — and what user_identifier actually is.

What user_identifier is

It is your name for your own user, carried through U2 Secured as an opaque string. Think of it as a foreign key pointing from U2 Secured back into your system. We never issue it, never validate it, and enforce no format.

Matching is exact

Lookups are exact string equality — no trimming, no case folding, no normalisation. Alice@acme.com and alice@acme.com are two different users as far as routing is concerned.

Case-sensitivity is deliberate

user_identifier is your key into your own system, not ours — we fold nothing because collapsing case could silently merge two genuinely different users. Use the exact same string when you mint the pairing code and again on every later requestPush call; a casing or typo mismatch is the most common cause of an unexpected ENROLMENT_NOT_FOUND. It does not apply to TOTP — verifyTOTP takes a secret and no identifier at all.

Choosing one

Prefer something stable and internal — your primary-key user ID is the right answer. An email is convenient but breaks the moment someone changes theirs.

There is no rename endpoint, and re-pairing under a new identifier is ADDITIVE rather than a rename: the old enrolment survives it. requestPush against the old string keeps reaching their devices and keeps approving sign-ins, indefinitely. Retiring the old label is your call — call deleteEnrolment on it explicitly once the new one is paired.

Mint a pairing code

The code is valid for 10 minutes and can be redeemed once.

main.go
code, err := client.CreatePairingCode("user-8f21")
if err != nil {
    return err
}
// code.Code      => "3f9a-c210"
// code.ExpiresAt => "2026-07-31T10:20:00Z"

The user redeems it

They type the code into the U2 Secured app. That is the step that binds your identifier to their account and their device — nothing else does.

Re-pairing moves one identifier — it never retires another

If someone redeems a code for an identifier that is already paired, THAT identifier moves to the new device or account. That is how device replacement works — and it also means a code sent to the wrong person hands them that identifier. What it is not is a rename: pairing the same person under a NEW identifier adds a second enrolment and leaves the first one live, still able to approve their sign-ins. One account can deliberately hold several identities on one app, so the server cannot tell a rename from a second identity and will not guess — delete the old enrolment yourself with deleteEnrolment.

Enrolments are per-app

An enrolment belongs to the app your API key names. The same identifier under two different apps is two unrelated links, and one app’s key can never touch another’s.

Listing enrolments

listEnrolments returns a page of the enrolments for this app — the app is resolved from your API key, so there is no app id parameter here either. Page through the whole set with a loop:

main.go
cursor := ""
for {
    page, err := client.ListEnrolments(&u2auth.ListEnrolmentsOptions{Limit: 50, Cursor: cursor})
    if err != nil {
        return err
    }
    for _, e := range page.Items {
        fmt.Println(e.UserIdentifier, e.LastAuthAt)
    }
    if page.NextCursor == "" {
        break
    }
    cursor = page.NextCursor
}
ParameterDefaultMeaning
limit25Page size. Requesting fewer than 1 or more than 100 raises INVALID_LIMIT.
cursor—Opaque page token copied from the previous page’s next_cursor / NextCursor. Omit it to fetch the first page.
sortcreated_atWhich field orders the results: created_at, user_identifier, or last_auth_at.
orderdescSort direction.

The cursor is opaque — pass it back verbatim

It is bound to the sort and order that minted it. Resending it after changing either one is rejected with 400 INVALID_CURSOR rather than silently reset to page one — always keep the same sort/order alongside the cursor for the life of a paging loop, and never construct or parse the string yourself.

For an enrolment that has never authenticated, last_auth_at and last_auth_outcome (however each language spells them) are both null — never a zero time or an empty string.

The shape of an enrolment

Every enrolment listEnrolments and getEnrolment hand back carries these fields, however each language spells them.

FieldMeaning
idStable identifier for the link itself — this is what listEnrolmentEvents takes.
user_identifierThe identifier you chose when the pairing code was minted.
created_atWhen the link was created.
last_auth_atWhen this enrolment last authenticated, or null if it never has.
last_auth_outcomeHow that last attempt ended, or null if it never authenticated.
push_readytrue when a confirmed device is attached, so a push would reach them. false means linked but unfinished — requestPush answers NO_DEVICE.

push_ready is computed from exactly the condition the push fan-out applies, so it answers “would a push reach them?” rather than “is there a row?”. Checking it before you push

Authentication events

listEnrolmentEvents returns the authentication log for one enrolment, newest first — one row per push or TOTP attempt. It pages the same way as listEnrolments, with its own cursor.

main.go
events, err := client.ListEnrolmentEvents("e_8f21", 25, "")
if err != nil {
    return err
}
for _, ev := range events.Items {
    fmt.Println(ev.Kind, ev.Outcome, ev.Place, ev.NewCountry)
}

The shape of an event

  • id — a stable identifier for this event.
  • kind — "push" or "totp", how the attempt was made.
  • outcome — the result, e.g. approved for a push or verified for a TOTP check.
  • created_at — when the attempt happened.
  • place — see below.
  • new_country — true when this is the first time this country was seen for the enrolment.

Locations are approximate

Locations are approximate, city-level, derived from GeoNames; exact coordinates are never returned. place is either null — no location fix was captured for that attempt — or a {city, region, country} object. On a present place, region can legitimately be an empty string for some cities; that is a normal lookup, not a failed one, and is distinct from place itself being null.

City and region data © GeoNames, licensed under CC BY 4.0.

Removing an enrolment already has its own method, with its own error handling — it isn’t repeated here. Jump to deleteEnrolment above