Theme
Open the portal

Sign in with allme

Add a “Sign in with allme” button to your website or app. The person taps it, confirms on their phone (or on the web), and your backend receives a verified identity — and, if you ask for them, a few profile fields or a lasting connection. It is a normal OAuth 2.0 authorization-code + PKCE flow with an allme-specific consent screen.

This is the identity-provider side of allme, distinct from the machine-to-machine service data API. You register a “Sign in with allme” app (an idw_… OAuth client) in the portal; the person consents on this client. All values a person shares are end-to-end encrypted to your app's key — the platform never sees them.

Which flow do I use?

Every request declares a mode. Pick by what you need from the person:

modeWhat you getPersistenceUse it when
signin The person's identity: a stable sub — which IS their share_code. (A display name is not part of identity: ask for it as a name claim.) None beyond the login. Read once from userinfo. You just need “log in / sign up with allme” — no profile data.
one_time Identity plus the specific fields you asked for (the claims), as a frozen one-time snapshot. The values are handed over once (E2E-encrypted to your app key) and are not kept in sync. Checkout / onboarding: you need an email, phone, or address this once and will store your own copy.
connect A lasting connection to one of your services — live, auto-updating fields over the service data API and webhooks. Permanent until the person disconnects. Values stay current. You want an ongoing relationship (a customer record that stays up to date). The app must reference a service.

A fourth mode, 2fa_enroll, uses this same surface to enroll the person in 2FA by allme for your service; it delivers no identity or claims.

Relation to the other connect paths. connect mode is a thin front door on top of the normal service connection: the person answers your service's request fields on the consent screen itself — one screen, no second dialog — and approving creates or joins a regular service connection you then read with your service data client. It also returns the answers to you directly, exactly as one_time does: call userinfo and read values. The consent screen answers every row of your service's catalog, binary rows included (those whose type resolves to a file storage lane — Field types); a file reaches you over the service data API, not in values. connect needs your company on the Basic level or above. signin/one_time live entirely on this OAuth surface — no data client, no service required.

How do I get the result back — redirect or detached?

Independently of mode, pick a response_mode for how the authorization code reaches you:

response_modeHow it worksBest for
redirect default After consent the person's browser/app is redirected to your redirect_uri?code=…&state=…. Classic OAuth. Desktop web, or any context where the same browser tab that started the flow should land back on your callback.
detached Consent happens on the person's phone; nothing redirects your page. Your backend polls POST /oauth2/result with the state until the code appears (delivered exactly once). “Approve on your phone” UX, cross-device sign-in, and avoiding a stray browser tab.

1. Register your app

In the allus portal → Configuration → OAuth apps:

  1. Create an app with a name, one or more exact redirect URIs (absolute https, or a custom scheme / app link for native apps — no wildcards), and choose public (PKCE) or confidential.
  2. Plain http is accepted for local development only — on localhost, 127.0.0.1, and the private IP ranges 10.x.x.x, 172.16–31.x.x and 192.168.x.x. That is what lets you test from a phone against a callback on your own machine: register the LAN address you actually browse to, for example http://192.168.1.50:8091/callback. Use the IP literal, not a hostname — a name such as mymachine.local is refused. Everything else must be https, and an http redirect sends the authorization code unencrypted across your network, so keep those registrations to development apps.
  3. The portal generates a zero-knowledge keypair in your browser: you download the private key (protected by a passphrase shown once). This key decrypts the claim values delivered in one_time and connect mode; the platform only stores the public key.
  4. You get a client_id (an idw_… id) and, for confidential apps, a client_secret shown once.
  5. For connect apps, reference one of your services. Optionally require 2FA (also settable per service).
The private key is needed only if you receive claim values (one_time or connect, or an encrypted-delivery OIDC login). A pure signin integration needs no key handling at all — just the client_id.

2. Send the person to the consent screen

Point your button at https://web.allme.fyi/auth with the request in the query string. The allme apps claim that link (universal / app link) and open the native consent screen; the web app is the no-app fallback.

<a href="https://web.allme.fyi/auth?client_id=idw_...&mode=signin&response_mode=redirect
        &redirect_uri=https%3A%2F%2Fshop.example%2Fcallback&state=RANDOM">
  Sign in with allme
</a>

Parameters: client_id, mode (signin|one_time|connect), response_mode (redirect|detached), redirect_uri (URL-encoded, must exactly match a registered one), state (your CSRF nonce — always verify it on return), and for public clients a PKCE code_challenge (+code_challenge_method=S256).

claims is honoured in connect mode too, and what the consent screen asks for is the resolved claim set — not your URL's array on its own. The resolved set is your request's claims combined with every claim your company declared on the OAuth app (plus, on a standards OIDC sign-in, the scope presets you asked for). Where the same claim name appears in both, the declaration is a floor: required and verified are raised and never lowered, mandatory_connected, the hint and the stay-connected binding come from the declaration, and your request keeps type, suggest and label. So sending claims does not narrow the screen to your array: a claim your company declared and your code never mentioned still renders. If a row appears that your request did not ask for, look at the OAuth app's claim configuration in the portal before you debug your request.

Which SOURCE the screen uses is a separate question, and there the answer really is one or the other. connect renders either the resolved claim set or your service's own request catalog, never a blend of the two: the claims source is used when the resolved set is non-empty, and the catalog only when it is empty — i.e. when your app declares nothing and your request names nothing, which is what an integration that has never touched claims gets. One further way to land on the catalog in connect: a claim is only rendered here if it carries the 1:1 stay-connected binding to one of your service's request rows, since that is the slot the live connection is keyed by. If none of the resolved claims has one, the screen falls back to the catalog rather than refusing.

Requesting fields — claims

Add a URL-encoded JSON array claims. A claim is a request field: you say what you need — a name, a field type, optionally a suggested slug, whether it is required, and whether only a verified value will do — and the person decides which of their own fields answers it. You never name one of their fields.

[
  { "name": "email", "type": "email", "suggest": "email_personal", "required": true, "verified": true },
  { "name": "phone", "type": "phone" },
  { "name": "shipping", "type": "address", "label": "Delivery address" }
]

The consent screen shows one row per resolved claim (see above — your request's claims combined with the ones your company declared), pre-selecting the suggested field and letting the person pick another same-type field or type a value. Binary types (photo, document) are not claimable. The SDKs build and encode this for you.

The 15-claim cap applies to the RESOLVED set, and your request is the side that gets cut. Declared claims and scope presets occupy the leading slots — deliberately, so that what a standards-only relying party needs survives — so if the declared claims plus your own exceed 15, it is the tail of your array that is dropped. Count both sides, not just the array you send.
name is required, and it is your key for everything that comes back. values and values_attestation are keyed by it, and so is the person's stored choice for a repeat login. Two claims may not share a name — the request is rejected rather than one answer silently overwriting the other. For the standard OIDC scopes the names are the standard claim names (email, name).

verified — "only a verified value will do". The person still sees their unverified fields (hiding them would look like they have none), but selecting one is refused on the spot with a prompt to verify it, and typing a value is not offered at all. Two limits, both refused loudly at authorize with error=invalid_request rather than quietly ignored: verified is not available on the one_time leg (it carries no source-field reference, so allme can neither enforce nor attest it), and only field types allme can verify accept it — data the platform's admin panel controls, currently email, phone, and the name/date-of-birth/document-number attributes an identity-document or address check establishes. A binary identity-document value is excluded — it can never travel as an inline claim.

If your integration cannot send claims at all — a standards-only OIDC library driving scope=openid email profile — you can still get everything below: configure it per claim on the app in the allus portal, beside the redirect URIs.

The portal is where you DECLARE your claims, and what you declare takes effect at sign-in — including when your own code asks for nothing beyond the standard scopes. Each declared claim carries a name, a contact-field type, required, verified, an optional hint (the sentence the person reads saying why you need it), the 1:1 stay-connected field binding, and mandatory_connected — the person must keep that field connected; a one-off value is not an answer. The two standard OIDC claims email and name keep required, verified and their binding only: their type is fixed in the portal, because your library expects those claim names in the id_token exactly as the spec defines them — a request that sends its own claims still declares type, suggest and label per transaction, exactly as before. For the same reason a claim you declare yourself may not be named email or name: it would key the same configuration entry and replace the standard one, so it is refused.

Claim names are 1–40 characters of a-z, 0-9 and _, may not be a registered JWT/OIDC claim (sub, aud, exp, …), and are taken verbatim — a name with surrounding whitespace is refused rather than tidied up, because the name is an identity your integration keys off.

If you read and write this config from your own automation: a declared claim carries type, required, mandatory_connected, verified, service_field_id and hint; a standard email/name entry carries only required, verified and service_field_id. Those are the same shapes the API returns, so you can write back what you read, unchanged. Adding the other three fields to a standard claim is refused rather than ignored, so a setting that could never take effect is never silently accepted.

The registered config is a FLOOR. A request that DOES send claims may make them stricter — turning required or verified on — and can never turn them off: what your admin configured in the portal is not something your code can silently undo. mandatory_connected, the hint and the binding are portal-only and a request cannot express them at all.

Two limits worth knowing before you configure one. A mandatory_connected claim, and any verified claim, cannot be honoured on the one_time leg — that leg delivers a value frozen at consent and carries no reference to the field it came from — so a sign-in in that mode fails with invalid_request rather than quietly downgrading. And a claim you declare yourself needs claims_delivery = encrypted: under plaintext delivery only the standard email/name claims can travel, because they are the only ones the id_token can carry.

Plugin claims

A claim you declare may instead be answered through a plugin your company added in the portal — a field the person fills in by searching and picking from options a plugin serves, with outputs the plugin computes (Building plugins). You declare it on the app in the portal (plugin, field type, which of your other claims feed its inputs, required, hint). In the app's claim config it reads:

{ "wage_scale": { "type": "plugin", "required": true, "hint": "To calculate your wage",
                  "plugin_spec": { "plugin_id": "019…", "field_type": "cao",
                                   "inputs": { "age_at_start": "age" } } } }

The value arrives in values[name] like any other claim, decrypted by the SDK to the plugin answer's JSON string — {"plugin", "type", "blocks", "outputs"}. Read it with parsePluginValue:

from allus_company_data import parse_plugin_value

scale = parse_plugin_value(result["values"]["wage_scale"])
for o in scale.outputs:
    print(o["label"], o["value"])        # Minimum wage 9.5 …
import { parsePluginValue } from '@allus-fyi/company-data';

const scale = parsePluginValue(result.values.wage_scale as string);
for (const o of scale.outputs) console.log(o.label, o.value);   // Minimum wage 9.5 …
use Allus\CompanyData\OAuthClient;

$scale = OAuthClient::parsePluginValue($result['values']['wage_scale']);
foreach ($scale->outputs as $o) {
    echo $o['label'], ' ', $o['value'], PHP_EOL;   // Minimum wage 9.5 …
}
scale, err := companydata.ParsePluginValue(result.Values["wage_scale"])
if err != nil {
    log.Fatal(err)
}
for _, o := range scale.Outputs {
    fmt.Println(o.Label, o.Value)                  // Minimum wage 9.5 …
}
var scale = OAuthClient.ParsePluginValue(result.Values["wage_scale"]);
foreach (var o in scale.Outputs)
    Console.WriteLine($"{o.Label} {o.Value}");      // Minimum wage 9.5 …
PluginValue scale = OAuthClient.parsePluginValue(result.values().get("wage_scale"));
for (PluginValue.Output o : scale.outputs()) {
    System.out.println(o.label() + " " + o.value());   // Minimum wage 9.5 …
}
A plugin answer is unsigned. It is what the person's client submitted — sealed to your app key, not signed by the plugin. When you must rely on an output, check it with the plugin itself.

3. Receive the code & read the identity

redirect: the person lands on redirect_uri?code=…&state=…. detached: poll for it:

# detached: poll until the person approves on their phone
curl -s -X POST https://api-eu.allme.fyi/oauth2/result \
  -d client_id=idw_... -d client_secret=...   # secret only for confidential apps
  -d state=RANDOM
# → 202 (pending) … then → { "code": "def502...", "state": "RANDOM" }  (once)

The poll answers 401 with error_key: "oauth.invalid_client" for an unknown or deleted app and for a wrong secret alike, and 400 with error_key: "oauth.invalid_request" when state is missing. The SDKs' poll_result raises either as ApiError.

A 503 with error_key: "db.writes_paused" on this poll means saving is paused on the platform: nothing was written and the result was not consumed, so wait Retry-After (30 seconds) and poll again. The SDKs' poll_result raises it as ApiError rather than retrying. The code exchange at /oauth2/token keeps working meanwhile.

A 503 with error_key: "platform.out_of_order" means allme is temporarily out of order: the request was not processed and the result was not consumed, so wait Retry-After (300 seconds) and poll again. The code exchange at /oauth2/token and userinfo answer the same 503 meanwhile. The SDKs raise it as ApiError rather than retrying.

Exchange the code at /oauth2/token (standard authorization-code, with your PKCE verifier or secret). The token response adds mode. Then call userinfo with that token:

curl -s https://api-eu.allme.fyi/api/oauth/userinfo -H "Authorization: Bearer ey..."
# → { "sub": "2I6UF3", "share_code": "2I6UF3",
#     "mode": "one_time", "two_factor": true,
#     "values": { "email": "{\"_enc\":1,...}", "phone": "{\"_enc\":1,...}" },
#     "values_attestation": { "email": "{\"_enc\":1,...}" } }

values is present for one_time, for connect and for an OIDC login on an encrypted-delivery app; each value is ciphertext encrypted to your app's public key — decrypt it with the private key you downloaded (RSA-OAEP-SHA256 + AES-256-GCM). The values live only as long as the short-lived token; read them promptly. The person's own field id is never exposed — values are keyed by the name you gave the claim — and when connect shows your service's request catalog instead, by the slug of the request field, never by an internal row id.

sub is the share code, and there is no display_name. sub and share_code are the same value, and sub is byte-identical to the one in the id_token — so the standard "userinfo sub must match the id_token's" check passes. To get the person's name, ask for it as a claim: Claim(name="name", type="text"), then read values["name"]. The person chooses which of their own text fields answers it, so it is their choice rather than a value you get unasked.

values_attestation — proving a value is verified

When you ask for a claim with verified: true and the app delivers encrypted, userinfo carries an additive sibling map values_attestation, keyed by the same claim name as values. Each entry is a second {"_enc":1,…} object sealed to your app key; decrypt it the same way and you get {"hash", "salt", "verified_at", "verified_expires_at"}, plus the proof trio {"verified_method", "verified_provider", "verification_id"} described below. Check sha256(salt ‖ plaintext) == hash against the value you just decrypted — lowercase hex, and compare in constant time. The SDKs do all of this for you and hand you a computed verified boolean (see the quickstart below).

allme builds and seals this server-side — a client-supplied attestation is never accepted — so it attests allme's own record of the field the person chose.

The seal also says HOW the value was proved, in three members that arrive together or not at all: verified_method (email_code, sms_code, sumsub_id or sumsub_address), verified_provider (allme for a code allme itself sent, sumsub for an identity or address check) and verification_id — the proof id you quote back to allme when a value is disputed, which resolves the full record of that verification act. A value verified before this record existed seals the first four members and none of these three. They are additive inside the seal, so an older SDK still opens it, and they are informational: the hash recompute above stays the only trust decision.

The SDK quickstart

Each SDK ships an OAuth module that builds the URL, exchanges the code, and decrypts the claim values from config — you never touch a key in code.

from allus_company_data import OAuthClient, Claim

oauth = OAuthClient.from_config("idw.json")   # client_id, redirect_uri, private key + passphrase

# 1) the button URL (checkout wants an email once)
url = oauth.authorize_url(mode="one_time", claims=[Claim("email", "email", suggest="email_personal", required=True)], state=state)

# 2) on your callback (or after poll_result for detached):
result = oauth.complete_sign_in(code, code_verifier=verifier)
print(result["user"]["sub"], result["mode"], result["values"]["email"])   # decrypted
# the ciphertext it came from is result["values_cipher"]["email"]
# a verified claim also lands in result["attestations"]["email"].verified
import { OAuthClient, Claim } from "@allus-fyi/company-data";

const oauth = OAuthClient.fromConfig("idw.json");
const url = oauth.authorizeUrl("one_time", { claims: [{ name: "email", type: "email", suggest: "email_personal", required: true }], state });

// on your callback:
const result = await oauth.completeSignIn(code, { codeVerifier });
console.log(result.user.sub, result.mode, result.values.email);
// the ciphertext it came from is result.values_cipher.email
// a verified claim also lands in result.attestations.email.verified
use Allus\CompanyData\OAuthClient;
use Allus\CompanyData\Claim;

$oauth = OAuthClient::fromConfig('idw.json');
$url = $oauth->authorizeUrl('one_time', [new Claim('email', 'email', suggest: 'email_personal', required: true)], state: $state);

// on your callback:
$result = $oauth->completeSignIn($code, codeVerifier: $verifier);
echo $result['user']['sub'], $result['mode'], $result['values']['email'];
// the ciphertext it came from is $result['values_cipher']['email']
// a verified claim also lands in $result['attestations']['email']['verified']
import (
    "fmt"
    "log"

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

oauth, err := companydata.OAuthClientFromConfig("idw.json")   // client_id, redirect_uri, private key + passphrase
if err != nil {
    log.Fatal(err)
}

// 1) the button URL (checkout wants an email once)
authURL, err := oauth.AuthorizeURL("one_time", &companydata.AuthorizeURLOptions{
    Claims: []companydata.Claim{{Name: "email", Type: "email", Suggest: "email_personal", Required: true}},
    State:  state,
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("send the user to:", authURL)

// 2) on your callback (or after PollResult for detached):
result, err := oauth.CompleteSignIn(code, verifier)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result.User["sub"], result.Mode, result.Values["email"])  // decrypted
// the ciphertext it came from is result.ValuesCipher["email"]
// a verified claim also lands in result.Attestations["email"].Verified
using Allus.CompanyData;

var oauth = OAuthClient.FromConfig("idw.json");

// 1) the button URL (checkout wants an email once)
var url = oauth.AuthorizeUrl("one_time",
    claims: new[] { new Claim("email", "email", Suggest: "email_personal", Required: true) },
    state: state);

// 2) on your callback (or after PollResultAsync for detached):
var result = await oauth.CompleteSignInAsync(code, codeVerifier);
Console.WriteLine($"{result.Sub} {result.Mode} {result.Values["email"]}");  // decrypted
// the ciphertext it came from is result.ValuesCipher["email"]
// a verified claim also lands in result.Attestations["email"].Verified
import fyi.allme.allus.companydata.OAuthClient;

OAuthClient oauth = OAuthClient.fromConfig("idw.json");

// 1) the button URL (checkout wants an email once)
String url = oauth.authorizeUrl("one_time", new OAuthClient.AuthorizeOptions()
    .claims(List.of(new OAuthClient.Claim("email", "email", "email_personal", true, false, null)))
    .state(state));

// 2) on your callback (or after pollResult for detached):
OAuthClient.SignInResult result = oauth.completeSignIn(code, verifier);
System.out.println(result.user().get("sub") + " " + result.mode()
    + " " + result.values().get("email"));  // decrypted
// the ciphertext it came from is result.valuesCipher().get("email")
// a verified claim also lands in result.attestations().get("email").verified()

For detached, use poll_result(state) instead of a callback — it loops the /oauth2/result poll and returns the code.

The progressive sign-up pattern

Start every returning user with signin. If they are new (your backend doesn't recognise the sub), immediately send them through again with one_time claims to collect what you need for sign-up — the second consent is one tap because they are already signed in. One button, no separate “register” form.

iOS caveat. On iOS, a universal link only opens the app when it is a genuine user tap on an <a href> — not a JavaScript window.location redirect. So make the progressive second step a real button/link the person taps, not an automatic redirect, or iOS will open the web fallback instead of the app.

Security notes