Documentation menu
SDK

Linked approvals

Mint a pairing code, watch it get redeemed, and find out whether a user can actually be pushed to — in all six SDKs.

What linking is

A push approval can only reach someone whose device is linked to one of your user identifiers. Linking is a pairing code: your backend mints one, the user types it into the U2 Secured app, and that redemption creates the enrolment everything else hangs off.

  1. 1Your backend calls createPairingCode with your own identifier for the user.
  2. 2You show them the code. It is valid for 10 minutes and can be redeemed once.
  3. 3They type it into the U2 Secured app. Codes are matched case-insensitively, so an autocapitalising phone keyboard is fine.
  4. 4Your backend learns it was redeemed — by polling getPairingCodeStatus, or by blocking on waitForLink inside a worker.
  5. 5From then on getEnrolment says whether that identifier is linked, and push_ready says whether a push would reach them.

Mint a pairing code

Pass your own opaque identifier for the user. The same string has to be used on every later requestPush call — matching is exact and case-sensitive.

main.go
code, err := client.CreatePairingCode("user-8f21")
if err != nil {
    return err
}
// Show code.Code to the user; it lapses at code.ExpiresAt.

Ask where a code is

getPairingCodeStatus reads a code’s position in its lifecycle without consuming it, so it is safe to call on a timer. Once it reads redeemed it also carries the id of the enrolment that was created.

main.go
status, err := client.GetPairingCodeStatus(code.Code)
if err != nil {
    return err
}
switch status.Status {
case "pending":
    // Nobody has redeemed it yet — poll again.
case "redeemed":
    // status.EnrolmentID names the enrolment that was just created.
case "expired":
    // Gone. See below: this also covers "never existed".
}

expired also means “never existed”

A code that was never minted, and a code belonging to a different app, both read as expired — the cases are deliberately indistinguishable. Telling them apart would make this endpoint an oracle over the code space and let one app probe another’s codes. Treat expired as “mint a fresh one”, never as “this code is wrong, try typing it again”.

Wait for the link

waitForLink polls the code for you and returns the resulting enrolment, mirroring waitForApproval so that knowing one means knowing the other. The user identifier is required — it is how the enrolment is fetched once redemption is seen.

main.go
enrolment, err := client.WaitForLink(ctx, code.Code, &u2auth.WaitForLinkOptions{
    UserIdentifier: "user-8f21",  // required
})
if err != nil {
    var apiErr *u2auth.APIError
    if errors.As(err, &apiErr) && apiErr.Code == "LINK_TIMEOUT" {
        // Nobody redeemed it before the deadline.
    }
    return err
}
// enrolment.ID is the link that was just created.

waitForLink blocks — keep it off the request path

It occupies the calling thread, worker or process for the whole timeout, two minutes by default. Under PHP-FPM that pins a worker, and a login page that calls it will exhaust the pool under modest traffic. Run it from a background worker instead, or poll getPairingCodeStatus from the browser on an interval — which is what the WordPress plugin does.

Is this user linked?

getEnrolment looks up one identifier and hands back the enrolment, or the language’s empty value when there is none.

main.go
enrolment, err := client.GetEnrolment("user-8f21")
if err != nil {
    return err   // A real failure — transport, auth, server.
}
if enrolment == nil {
    // Not linked. Mint a pairing code and send them through enrolment.
}

“Not linked” is an answer, not an error

getEnrolment returns null, None or nil — never an exception — when the identifier has no enrolment. At login that is the normal answer for everyone who has not enrolled yet, and forcing callers into try/catch on the common path is how integrations end up swallowing real errors too. An error out of getEnrolment therefore means a real failure: transport, authentication, or the server. deleteEnrolment is the opposite case and does raise ENROLMENT_NOT_FOUND, because there the absence genuinely is the anomaly.

Can they actually be pushed to?

An enrolment can exist while no device is confirmed — pairing was started on the phone and never finished. push_ready on the enrolment reports whether at least one confirmed, non-superseded device is attached.

main.go
if enrolment != nil && !enrolment.PushReady {
    // Linked, but no confirmed device — RequestPush would fail with NO_DEVICE.
    // Fall back to TOTP, or send them back to finish pairing on the phone.
}

It is the same condition the push fan-out applies, so push_ready being false and a NO_DEVICE failure are the same fact learned at two different moments. Reading it first lets you route the user to TOTP, or back through enrolment, instead of surfacing a push that failed.