Documentation menu
Push

Requesting an approval

Send the push, show the number, wait for the tap.

Before you start: install the SDK

Add the package and construct one client at startup — every call below is a method on it. Full install notes, including lock-file managers and why Python needs pip rather than pipx, are on the SDK install page.

main.go
go get github.com/u2secured/u2auth-go

import u2auth "github.com/u2secured/u2auth-go"
client := u2auth.NewClient("rka_your_api_key", nil)

Send a push approval

The context string is what the user sees on their phone, so make it specific — "Login from Chrome on macOS" beats "Login".

main.go
push, err := client.RequestPush("user-8f21", "Login from Chrome on macOS", nil)
if err != nil {
    return err
}
// Show push.MatchNumber to the user.

ENROLMENT_NOT_FOUND means the identifier itself doesn’t match

A 404 with code ENROLMENT_NOT_FOUND means nothing was ever paired under this exact user_identifier for this app. Because matching is case-sensitive, this is very often a casing or typo mismatch against the identifier used when the pairing code was created — check that before assuming the user needs to enrol again.

NO_DEVICE means they paired, but no device is confirmed yet

A 409 with code NO_DEVICE means the identifier resolved to a real enrolment, but that enrolment has no confirmed device to push to — for example, pairing was started but never finished on the phone. Send them back through enrolment to finish confirming a device.

LOCATION_REQUIRED means the developer’s policy demands a location fix

A 400 with code LOCATION_REQUIRED means your policy (Dashboard → Security → Policy, per-developer default or per-app override) has "Require location to approve" turned on, and this approval request carried no location. Deny is never gated — only approve. There is no grandfathering and no bypass: a user who cannot supply a location, including one who enrolled before you turned this on, cannot approve until they grant permission, and on iOS a user who already declined must do that from Settings themselves. The only recovery path when a user is truly stuck is removing their enrolment and having them re-link.

Idempotent retries

A double-clicked submit or a back-then-resubmit can call requestPush twice for the same login attempt. Pass an Idempotency-Key header and a repeat within that request’s lifetime returns the original result instead of paging the user’s phone a second time.

HTTP
POST /api/v1/sdk/push/request HTTP/1.1
Host: api.u2secured.com
Authorization: Bearer rka_your_api_key
Idempotency-Key: 3fa6a6e2-9b8f-4e2b-9c1e-3f1c6a1a9d2e
Content-Type: application/json

{
  "user_identifier": "user-8f21",
  "context": "Login from Chrome on macOS"
}

This is an HTTP header, so it works the same for every caller regardless of language — and every SDK now wraps it for you: idempotency_key on Python’s request_push, Node’s PushOptions and PHP’s $opts array, IdempotencyKey on Go’s PushOptions and C#’s, idempotencyKey on Java’s. In each one it is sent as the header and never as a body field, since a key in the body is silently ignored and the duplicate push goes out anyway.

  • The header is optional. Leave it out and every call mints a new approval — today’s default behaviour, unchanged.
  • While the original approval is still pending, a repeat with the same key returns it unchanged: same approval_id, same match_number, status 201, and no second push sent to the phone.
  • Keys are scoped per app, so two different apps can reuse the same string without ever colliding.
  • Keys top out at 255 characters. Go over that and the call fails with 400 INVALID_IDEMPOTENCY_KEY.
  • Reusing a key that a still-live approval holds, for a request that doesn’t match it — a different user or a different context — fails with 409 IDEMPOTENCY_KEY_REUSED. That’s the guard against one key accidentally spanning two different people.

This is not Stripe-style idempotency

There is no fixed 24-hour replay window, and no stored response being replayed back to you. A key is only live for as long as its approval is pending — the moment that approval is approved, denied or expires, the key frees itself. A genuine retry after that point is treated as a brand-new request and mints a new approval, exactly as if the key had never been sent. A client built to Stripe’s semantics will keep resending the same key expecting the same answer forever, and instead start getting fresh approvals back — breaking on the very first expiry.

The SDK does not generate the key for you

There is no generateIdempotencyKey helper, and there will not be one. A key invented inside the call is a new key on every call — it protects nothing, because protecting a retry means presenting the exact same key the first attempt used, and only your code knows which attempt is a retry of which.

The pattern that works: mint a nonce when the login form is rendered, and carry it in a hidden field. A double-clicked submit and a back-then-resubmit of that same page both send the same nonce and collapse into one approval. A genuine fresh page load mints a new nonce and correctly starts a new one.

login.html
<!-- rendered once, when the login form loads -->
<input type="hidden" name="idempotency_key" value="{{ nonce }}">
app.py
# app.py — Python is the SDK that wraps this today
push = client.request_push(
    "user-8f21",
    "Login from Chrome on macOS",
    idempotency_key=request.form["idempotency_key"],
)

Number matching

The push result carries a match_number. Show it to the user in your own UI — their phone offers several numbers and only the matching one approves.

Display the match number

It is what stops MFA-fatigue approvals, not decoration. Without it on your screen the user has nothing to compare against and is guessing.

Learning the outcome

Three ways, from least to most efficient:

  • getPushStatus — a single poll. Fine for a quick check; you drive the loop.
  • waitForApproval — blocks until the approval resolves or the timeout fires with WAIT_TIMEOUT. Simplest for a synchronous login flow.
  • Webhooks — we call you the moment it resolves. No polling, and the only option that scales.
main.go
status, err := client.WaitForApproval(ctx, push.ApprovalID, nil)
if err != nil {
    if errors.Is(err, u2auth.ErrWaitTimeout) {
        // Nobody tapped in time.
    }
    return err
}
// status.Status is "approved", "denied" or "expired".