Theme
Open the portal

2FA by allme

Use allme as the second factor for your own login. When a person signs in to your product, you ask them to approve it inside the allme app — a push, a card, a biometric tap. You raise the challenge, the person approves or denies on their phone, and you read the outcome by poll or webhook. Optional number matching defeats push-fatigue and shoulder-surfing.

This is your login's second factor, delivered through allme. It is distinct from Sign in with allme and the OIDC provider, which authenticate the person to allme. Here the person authenticates to you however you already do; allme only confirms it is really them. It runs on your existing service data-client credentials — no new keys, no OAuth code. One prerequisite: that client's endpoint whitelist must include /api/service-2fa/*, which a data client receives at registration. An older client that has not been backfilled yet returns 403 {"error":"Client not authorized for this endpoint","error_key":"auth.client_endpoint_forbidden"} on the calls below — register a fresh data client on the portal if you hit that.

How it fits together

  1. Enroll — the person opts your service in as a 2FA approver, once (a consent on the allme surface).
  2. Challenge — at each login you POST a challenge naming the person by their share code. allme pushes a card to their devices.
  3. Approve / deny — the person confirms in-app (biometric-gated), optionally re-typing two matching digits you show on your login page.
  4. Read the outcome — poll the challenge (authoritative) or receive the 2fa_challenge_completed webhook. Approve the login only on approved.

1. Enroll the person

Only an enrolled person can be challenged, and enrolling needs your company on the Pro level or above (an enrollment that already exists keeps working). Enrollment is a one-time consent on the same web.allme.fyi/auth surface as Sign in with allme, using mode=2fa_enroll. Point a button at it once, keyed to the service the person is (or will be) connected to:

<a href="https://web.allme.fyi/auth?client_id=idw_...&mode=2fa_enroll
        &redirect_uri=https%3A%2F%2Fapp.example%2Fsecurity&state=RANDOM">
  Use allme for 2-step verification
</a>

The person confirms, and a service_2fa_enrollments record is written. No code is minted and no scope or claim machinery runs — enrollment is its own grant, separate from authorizing an OAuth app or running a connect. An OIDC-only relying party enrolls through this same surface; there is no OIDC path for enrollment itself. The person can revoke you at any time from their app. Attempting to challenge a person who is not enrolled returns 404.

2. Raise a challenge

On the service data-client credentials (the same auth as the rest of the service API), POST a challenge naming the person by their share code. Like every other authenticated call, it goes to the api_url your token response returned, not to the global front door (Regions):

curl -s -X POST https://api-eu.allme.fyi/api/service-2fa/challenges \
  -H "Authorization: Bearer $SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"share_code":"2I6UF3","context":"Sign-in from Chrome on macOS","idempotency_key":"login-8f3c1a"}'
# → { "challenge_id": "...", "status": "pending", "expires_at": "2026-07-23T09:12:00+00:00",
#     "matching_digits": "47" }   // matching_digits only when number matching is on

Errors are 404 (unknown / not-enrolled share code), 429 (rate limit, or too many challenges already pending for this person) and 503 (twofa.unavailable — our challenge store could not be reached; transient, retry; or db.writes_paused — saving is paused, nothing was written: wait Retry-After seconds and send the same request again, see API → errors; or platform.out_of_order — allme is temporarily out of order, the request was not processed: wait Retry-After (300 seconds) and send the same request again, see API → errors). A malformed request is a 400 (twofa.share_code_required, twofa.idempotency_key_required, twofa.context_too_long). The challenge endpoint carries no level gate of its own; the data client it rides needs your company on the Basic level or above.

3. Read the outcome

Poll — the record

Poll the challenge until it leaves pending:

curl -s https://api-eu.allme.fyi/api/service-2fa/challenges/CHALLENGE_ID \
  -H "Authorization: Bearer $SERVICE_TOKEN"
# → { "status": "pending", "expires_at": "..." }               // still waiting
# → { "status": "approved", "completed_at": "..." }             // once — then burned
# → 410 { "error": "gone", "error_key": "twofa.result_consumed" }   // a later read

status is pending while open, then a terminal approved | denied | expired | revoked; a read after that answers 410 twofa.result_consumed (an SDK raises it as ApiError). The poll is the record of the outcome: the first read of a terminal state delivers it and burns it, so read it once and persist your own decision. Grant the login only on approved; treat everything else — including expired — as a refusal.

A 503 is not an outcome — retry it. It means our challenge store could not be read, so the outcome is unknown and has not been burned; polling again returns it. Treating a 503 as a refusal would drop a login the person already approved. Every SDK raises its ApiError for this (carrying error_key: "twofa.unavailable"), including from waitForResult, so it can never be mistaken for a terminal status. A 503 with error_key: "platform.out_of_order" on the poll is the same: the request was not processed and the outcome has not been burned, so poll again after Retry-After (300 seconds).

Webhook — the push alternative

Rather than tight polling, subscribe to 2fa_challenge_completed on your service webhook (add it to the webhook's event list, or leave the list empty for all events). The body carries the same terminal state:

{
  "event": "2fa_challenge_completed",
  "challenge_id": "...",
  "share_code": "2I6UF3",
  "status": "approved",
  "completed_at": "2026-07-23T09:11:40+00:00"
}
The poll stays authoritative, not the webhook. Both webhook and pull feed are state feeds: if a second challenge to the same person supersedes an earlier one before you drain it, the earlier terminal state can collapse out of the feed. The burn-on-read poll never loses an outcome that way, so use the webhook to wake your worker and the poll to confirm — or poll alone. Deliveries are HMAC-signed like every other webhook; verify the signature before acting.

Number matching

Number matching is a per-service setting, and there is no control for it in the allus portal — you switch it on over the API:

# $API_URL = the api_url your token response returned (your home region)
curl -X PUT "$API_URL/api/services/{serviceId}/number-matching" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"number_matching": true}'

$TOKEN is a client_credentials token for an account API client (portal → Settings → API clients) holding the Service management capability. Your per-service data client cannot do this — its whitelist covers the company-data and 2FA surfaces, not /api/services*. Note the path: /require-2fa is a different, unrelated per-service flag.

When number matching is on, each challenge returns two digits in matching_digits. Show those digits on your login page; the person must type them back into the allme app to approve. The check is decisive because of where it runs:

Number matching turns a one-tap approval (vulnerable to a person reflexively approving a push they did not initiate) into a proof that the person is looking at your login screen right now.

The SDK quickstart

Each service SDK exposes a twoFactor sub-client on the same data client — challenge() to raise one, result() to poll once, or waitForResult() to poll to a terminal outcome for you. No new config; the return models are identical across all six languages.

from allus_company_data import Client

client = Client.from_config("allus.json")   # your existing service data client

# 1) raise a challenge at login
ch = client.two_factor.challenge("2I6UF3", context="Sign-in from Chrome", idempotency_key="login-8f3c1a")
if ch.matching_digits:
    show_on_login_page(ch.matching_digits)   # number matching is on

# 2) wait for the terminal outcome — waitForResult polls result() for you
#    (or wake on the 2fa_challenge_completed webhook)
res = client.two_factor.wait_for_result(ch.challenge_id)
if res.status == "approved":
    grant_login()
import { Client } from "@allus-fyi/company-data";

const client = Client.fromConfig("allus.json");

const ch = await client.twoFactor.challenge("2I6UF3", {
  context: "Sign-in from Chrome",
  idempotencyKey: "login-8f3c1a",
});
if (ch.matchingDigits) showOnLoginPage(ch.matchingDigits);

const res = await client.twoFactor.waitForResult(ch.challengeId);
if (res.status === "approved") grantLogin();
import (
    "context"
    "log"

    "github.com/allus-fyi/company-data-go/companydata"
)

client, err := companydata.FromConfig("allus.json")   // your existing service data client
if err != nil {
    log.Fatal(err)
}
ctx := context.Background()

// 1) raise a challenge at login
ch, err := client.TwoFactor().Challenge(ctx, "2I6UF3", "login-8f3c1a", "Sign-in from Chrome")
if err != nil {
    log.Fatal(err)
}
if ch.MatchingDigits != "" {
    showOnLoginPage(ch.MatchingDigits)   // number matching is on
}

// 2) wait for the terminal outcome — WaitForResult polls Result for you
//    (0, 0 takes the defaults: 600s budget, 2s between polls)
res, err := client.TwoFactor().WaitForResult(ctx, ch.ChallengeID, 0, 0)
if err != nil {
    log.Fatal(err)
}
if res.Status == "approved" {
    grantLogin()
}
using Allus.CompanyData;

var client = Client.FromConfig("allus.json");   // your existing service data client

// 1) raise a challenge at login
var ch = await client.TwoFactor.ChallengeAsync("2I6UF3", "login-8f3c1a", context: "Sign-in from Chrome");
if (ch.MatchingDigits is not null)
    ShowOnLoginPage(ch.MatchingDigits);   // number matching is on

// 2) wait for the terminal outcome — WaitForResultAsync polls ResultAsync for you
var res = await client.TwoFactor.WaitForResultAsync(ch.ChallengeId);
if (res.Status == "approved")
    GrantLogin();
import fyi.allme.allus.companydata.Client;
import fyi.allme.allus.companydata.TwoFactorChallenge;
import fyi.allme.allus.companydata.TwoFactorResult;

Client client = Client.fromConfig("allus.json");   // your existing service data client

// 1) raise a challenge at login
TwoFactorChallenge ch = client.twoFactor().challenge("2I6UF3", "login-8f3c1a", "Sign-in from Chrome");
if (ch.matchingDigits() != null) {
    showOnLoginPage(ch.matchingDigits());   // number matching is on
}

// 2) wait for the terminal outcome — waitForResult polls result() for you
TwoFactorResult res = client.twoFactor().waitForResult(ch.challengeId());
if ("approved".equals(res.status())) {
    grantLogin();
}
use Allus\CompanyData\Client;

$client = Client::fromConfig('allus.json');

$ch = $client->twoFactor()->challenge('2I6UF3', context: 'Sign-in from Chrome', idempotencyKey: 'login-8f3c1a');
if ($ch->matchingDigits !== null) showOnLoginPage($ch->matchingDigits);

$res = $client->twoFactor()->waitForResult($ch->challengeId);
if ($res->status === 'approved') grantLogin();

The argument set and both return models are identical in all six languages — only the naming follows each language's idiom. Installing and configuring the client is on the SDKs page; this needs no config beyond the data client you already have.

Two conveniences the SDKs add on top of the raw endpoints: waitForResult (above) polls to the first terminal state and raises on timeout, so you never hand-roll the loop; and a per-person pending-cap 429 (too many challenges already open for this person) surfaces immediately as an ApiError instead of being retried — a retry can't clear it. For a no-redirect, server-to-server enrollment the OAuth helper also accepts mode=2fa_enroll with response_mode=detached, and its poll_result returns {enrolled: true} once the person confirms.

What the person sees

The challenge arrives as a push and a card in the allme app's Requests screen, showing your service name and the context you supplied. Approve is protected by the person's biometric/passcode unlock — the same gate as revealing a private field — so an approval is always a fresh, deliberate act; Deny is one tap with no gate. Approving or denying on one device dismisses the card on the person's other devices instantly. An unanswered challenge simply expires.

Security notes