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.
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".
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.
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.
<!-- rendered once, when the login form loads -->
<input type="hidden" name="idempotency_key" value="{{ nonce }}"># 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.
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".