API reference
ReadyThe complete relying-party API — every call your backend makes on IdentityService, one collapsible card per RPC with its request and response fields, the typed errors it raises, and copy-paste Go examples today — more languages as each SDK publishes. Transport is gRPC over mutual TLS; there is no HTTP gateway.
Who this is for: the engineer integrating the relying-party surface, as an exhaustive lookup. For the narrative — how the calls fit into a ceremony — start with Your first ceremony and the guides. The device-facing surface is documented separately under Device-facing gRPC.
Every call is gRPC over mutual TLS — your client certificate identifies the relying party; there is no HTTP gateway and no API key. See Certificates & PKI to provision the cert, and Errors for the typed failure model every call shares.
The examples show idiomatic SDK code plus a raw-gRPC path in Go. Every call can also return the shared transport reasons — MTLS_CERT_NOT_PINNED, MTLS_CERT_EXPIRED, PERMISSION_DENIED, SERVER_INTERNAL, SERVER_UNAVAILABLE — so the per-call error tables list only the domain-specific reasons.
Users7 RPCs
Unary IdentityService.CreateUser Create one opaque identity 🔒 mTLS
Creates a single anonymous user and returns its opaque ID. The relying party is identified by its mTLS certificate, so the request carries no body.
Keep one opaque ID per real person for their lifetime, and hold the mapping on your side. Ydentifi cannot detect or merge duplicates — a second identity fragments a person’s devices and history. De-duplicate before you call.
Request — CreateUserRequest
No fields — the relying party is identified by its mTLS certificate.
Response — CreateUserResponse
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The 64-character hex identifier for the new user. Store it against your real record. |
Errors
| Reason | Raised when |
|---|---|
| LICENSE_USER_CAP_REACHED | the deployment holds more seats or users than its license allows, and refuses every call until its operator resolves it |
| MAX_USERS_REACHED | the relying party’s own seat cap (max_users) is reached; deleting a user frees a seat at once |
Examples
// The RP is identified by its mTLS client cert, so the request is empty. res, err := client.CreateUser(ctx, &identity.CreateUserRequest{}) if err != nil { /* handle error */ } opaqueID := res.OpaqueId // 64-char hex — store it against your user
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{rpCert}, // your client cert
RootCAs: trustPool, // your deployment CA
})
conn, err := grpc.Dial("ydentifi.acme.internal:443", grpc.WithTransportCredentials(creds))
if err != nil { /* handle */ }
defer conn.Close()
client := identity.NewIdentityServiceClient(conn)
res, err := client.CreateUser(ctx, &identity.CreateUserRequest{})
if err != nil { /* handle */ }
fmt.Println(res.GetOpaqueId())res = client.CreateUser(identity_pb2.CreateUserRequest()) opaque_id = res.opaque_id
const res = await client.createUser({});
const opaqueId = res.opaqueId;Server-streaming IdentityService.BulkCreateUsers Create many identities in one stream 🔒 mTLS
Creates many identities in a single server-streamed call — for a migration or a large signup. Chunks arrive as they are created; a final summary reports the totals.
oneof: zero or more chunk frames, then exactly one summary frame. Persist each chunk as it arrives.Running out of headroom mid-batch is not an error — the stream completes and summary.capped tells you how many were skipped. Check it rather than relying on the call to fail.
Request — BulkCreateUsersRequest
| Field | Type | Notes | |
|---|---|---|---|
| count | uint32 | required | How many identities to create (≥ 1). Capped at the RP’s remaining headroom. |
| idempotency_key | string | required | Re-sending the same key resumes an interrupted run instead of creating a second batch. |
Response — BulkCreateUsersFrame (stream)
| Field | Type | Notes | |
|---|---|---|---|
| chunk.opaque_ids | string[] | optional | The IDs created in this chunk. |
| chunk.created_total | uint32 | optional | Running total so far, inclusive. |
| summary.requested | uint32 | optional | The requested count. |
| summary.created | uint32 | optional | Total created (including any resumed partial). |
| summary.capped | uint32 | optional | How many were not created when headroom ran out (else 0). |
| summary.capped_reason | enum | optional | Why capping occurred (else UNSPECIFIED). |
Errors
| Reason | Raised when |
|---|---|
| INVALID_ARGUMENT | count < 1 or idempotency_key missing |
| LICENSE_USER_CAP_REACHED | the deployment holds more seats or users than its license allows. Raised when the stream opens or between chunks; the chunks already sent are created, and re-sending the same key resumes once it is resolved |
Examples
stream, err := client.BulkCreateUsers(ctx, &identity.BulkCreateUsersRequest{
Count: 1000,
IdempotencyKey: "batch-2026-07-24-a", // re-send to resume a partial run
})
if err != nil { /* handle */ }
for {
frame, err := stream.Recv()
if err == io.EOF { break }
if err != nil { /* handle */ }
switch f := frame.Frame.(type) {
case *identity.BulkCreateUsersFrame_Chunk:
store(f.Chunk.OpaqueIds) // persist as they arrive
case *identity.BulkCreateUsersFrame_Summary:
log.Printf("created %d of %d (capped %d)",
f.Summary.Created, f.Summary.Requested, f.Summary.Capped)
}
}Unary IdentityService.GetUser Read one user 🔒 mTLS
Returns a user’s status and timestamps.
Request — GetUserRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user to retrieve. |
Response — GetUserResponse
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | optional | The user’s ID. |
| status | UserStatus | optional | ACTIVE or LOCKED. |
| created_at | string | optional | ISO-8601 UTC. |
| last_authenticated_at | string | optional | ISO-8601 UTC. |
| last_activity_at | string | optional | ISO-8601 UTC. |
| totp_locked | bool | optional | True when the user’s codes are locked after 100 wrong codes in a row. VerifyUserTotp answers TOTP_LOCKED until IssueUserTotpEnrollment(reuse_existing=false) issues a new secret. status is not changed by it. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
Examples
res, err := client.GetUser(ctx, &identity.GetUserRequest{OpaqueId: opaqueID})
if err != nil { /* handle */ }
fmt.Println(res.Status, res.LastAuthenticatedAt)Server-streaming IdentityService.ExportUsers Stream every user out 🔒 mTLS
Streams all of the relying party’s users in server-sized batches — for a backup or an offline reconciliation. Resume from a cursor.
start_after_user_id to resume.Request — ExportUsersRequest
| Field | Type | Notes | |
|---|---|---|---|
| start_after_user_id | string | optional | Opaque ID to start after. Empty starts from the beginning. |
Response — ExportUsersFrame (stream)
| Field | Type | Notes | |
|---|---|---|---|
| users | GetUserResponse[] | optional | A batch of identities; the server dictates the batch size. |
Errors
| Reason | Raised when |
|---|---|
| INVALID_ARGUMENT | the cursor is malformed |
Examples
stream, err := client.ExportUsers(ctx, &identity.ExportUsersRequest{})
if err != nil { /* handle */ }
for {
batch, err := stream.Recv()
if err == io.EOF { break }
if err != nil { /* handle */ }
for _, u := range batch.Users { archive(u) } // server dictates batch size
}Unary IdentityService.UpdateUserStatus Lock or unlock a user 🔒 mTLS
Locks or unlocks a user without deleting them. A locked user cannot complete new ceremonies.
Request — UpdateUserStatusRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user to update. |
| status | UserStatus | required | ACTIVE or LOCKED. |
Response — UpdateUserStatusResponse
| Field | Type | Notes | |
|---|---|---|---|
| new_status | UserStatus | optional | The status now in effect. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
| INVALID_ARGUMENT | the status is UNSPECIFIED |
Examples
_, err := client.UpdateUserStatus(ctx, &identity.UpdateUserStatusRequest{
OpaqueId: opaqueID,
Status: identity.UserStatus_USER_STATUS_LOCKED,
})
if err != nil { /* handle */ }Unary IdentityService.DeleteUser Delete a user for good 🔒 mTLS
Removes a user and all of their device bindings. Irreversible.
This is permanent and cascades to every device the user enrolled. To pause a user instead, use UpdateUserStatus.
Request — DeleteUserRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user to delete. |
Response — DeleteUserResponse
| Field | Type | Notes | |
|---|---|---|---|
| success | bool | optional | True when the user and bindings were removed. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
Examples
_, err := client.DeleteUser(ctx, &identity.DeleteUserRequest{OpaqueId: opaqueID})
if err != nil { /* handle */ }Unary IdentityService.UnlockUserRequestLimits Clear a rate-limit quarantine 🔒 mTLS
Clears a user’s per-user rate limits at once, once you have resolved the cause. It does not unlock a user you locked with UpdateUserStatus (USER_LOCKED), nor codes locked after 100 wrong codes (TOTP_LOCKED): only IssueUserTotpEnrollment(reuse_existing=false) unlocks those.
Quarantines also clear on their own when auto_expires_at passes — see Limits & quotas. Only clear early once the abuse is understood, and treat it as a last resort: it resets every rate limit on that account, not just the one that tripped, so calling it repeatedly weakens real defenses. Confirm with the user before clearing, and don’t automate it — no scheduled jobs, no auto-retry-then-unlock loops.
Request — UnlockUserRequestLimitsRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The quarantined user. |
Response — UnlockUserRequestLimitsResponse
| Field | Type | Notes | |
|---|---|---|---|
| success | bool | optional | True when the quarantine was cleared. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
| RATE_LIMIT_EXCEEDED | this user’s unlocks are used up: 3 at once, then 1 every 10 minutes; once used up, none for 30 minutes |
Examples
_, err := client.UnlockUserRequestLimits(ctx,
&identity.UnlockUserRequestLimitsRequest{OpaqueId: opaqueID})
if err != nil { /* handle */ }Devices4 RPCs
Unary IdentityService.ListDevicesForUser List a user’s devices 🔒 mTLS
Returns every device enrolled for a user, of every status: active, inactive and banned. Inactive and banned rows are kept so you have the full forensic history.
Request — ListDevicesForUserRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user whose devices to list. |
Response — ListDevicesForUserResponse
| Field | Type | Notes | |
|---|---|---|---|
| devices | DeviceSummary[] | optional | Each carries device_id, hardware permanent_id, platform, status, timestamps, and an optional name. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
Examples
res, err := client.ListDevicesForUser(ctx,
&identity.ListDevicesForUserRequest{OpaqueId: opaqueID})
if err != nil { /* handle */ }
for _, d := range res.Devices {
fmt.Println(d.DeviceId, d.Platform, d.Status) // INACTIVE rows included
}Unary IdentityService.RevokeDevice Voluntarily deactivate a device 🔒 mTLS
Deactivates one device — the user swapped phones, or you are tidying up. The device can no longer sign; nothing is blocklisted. If the device was active, the user’s pending challenges end with it. The user’s TOTP secret is not replaced: the device’s copy keeps giving valid codes until IssueUserTotpEnrollment with reuse_existing false replaces it.
Revoke is a voluntary deactivation, not a blocklist. For a compromised or stolen device, use BanDevice, which denies the hardware and deactivates the active enrollment on it, if there is one (at most one per relying party).
Request — RevokeDeviceRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user the device belongs to. |
| device_id | string | required | The server-assigned UUID from ListDevicesForUser. |
Response — RevokeDeviceResponse
| Field | Type | Notes | |
|---|---|---|---|
| success | bool | optional | True on first revoke and on idempotent retries alike. |
Errors
| Reason | Raised when |
|---|---|
| DEVICE_NOT_FOUND | the (user, device) pair does not resolve for the calling RP |
Examples
_, err := client.RevokeDevice(ctx, &identity.RevokeDeviceRequest{
OpaqueId: opaqueID,
DeviceId: deviceID, // from ListDevicesForUser
})
if err != nil { /* handle */ }Unary IdentityService.BanDevice Blocklist compromised hardware 🔒 mTLS
Persistent denial for a compromised or stolen device. Resolves the row’s hardware permanent_id, blocklists it, and deactivates the active enrollment on that hardware, if there is one (at most one per relying party).
A ban targets the hardware, not just this one enrollment — it deactivates the active enrollment on the device’s permanent_id, if there is one (at most one per relying party), whichever user it belongs to. UnbanDevice lifts the block but does not resurrect that enrollment; the user must re-enroll.
A ban does not replace the user’s TOTP secret: the banned phone’s copy keeps giving valid codes. If the phone may be in someone else’s hands, call IssueUserTotpEnrollment with reuse_existing false after the ban.
Request — BanDeviceRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | A user whose device row identifies the hardware to ban. |
| device_id | string | required | The server-assigned UUID; the handler bans the hardware behind it. |
| reason | string | optional | Free-form note persisted for forensic audit (e.g. “lost report”). |
Response — BanDeviceResponse
| Field | Type | Notes | |
|---|---|---|---|
| success | bool | optional | True on first ban and on idempotent re-bans. |
| cascaded_device_count | int32 | optional | How many active enrollments the ban deactivated: 0 or 1 (0 is normal if already revoked). |
Errors
| Reason | Raised when |
|---|---|
| DEVICE_NOT_FOUND | the (user, device) pair does not resolve for the calling RP |
Examples
res, err := client.BanDevice(ctx, &identity.BanDeviceRequest{
OpaqueId: opaqueID,
DeviceId: deviceID,
Reason: "lost device report", // optional, persisted for audit
})
if err != nil { /* handle */ }
log.Printf("banned; %d active enrollments deactivated", res.CascadedDeviceCount)Unary IdentityService.UnbanDevice Lift a hardware ban 🔒 mTLS
Removes a device’s hardware from the blocklist so it may enroll again. It does not reactivate the enrollments the ban deactivated.
Unbanning clears the blocklist only. The previously-banned enrollments stay INACTIVE — the user re-enrolls from a fresh invite. See Enroll & link devices.
Request — UnbanDeviceRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user whose device identifies the banned hardware. |
| device_id | string | required | The server-assigned UUID of a row on that hardware. |
Response — UnbanDeviceResponse
| Field | Type | Notes | |
|---|---|---|---|
| success | bool | optional | True on first unban and on idempotent retries. |
Errors
| Reason | Raised when |
|---|---|
| DEVICE_NOT_FOUND | the (user, device) pair does not resolve for the calling RP |
Examples
_, err := client.UnbanDevice(ctx, &identity.UnbanDeviceRequest{
OpaqueId: opaqueID,
DeviceId: deviceID,
})
if err != nil { /* handle */ }
// the user must re-enroll — old enrollments are NOT restoredChallenges3 RPCs
Unary IdentityService.IssueAuthChallenge Ask a user to approve an action 🔒 mTLS
Issues a challenge over an exact action at the strength you require, and returns a challenge ID plus a Universal-Link QR to render or push to the device.
Put the exact action in context — the amount, the payee, the operation. It is hashed into the signed payload, so a man-in-the-middle who changes what the user sees invalidates the signature. And always set min_strength explicitly; there is no implicit tier.
Request — IssueAuthChallengeRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The target user; must be ACTIVE and owned by the calling RP. |
| ttl_seconds | uint32 | optional | Lifetime in [30, 300]. Zero means the 60-second server default. |
| context | bytes | optional | Display string (≤256 B) shown on the approval screen. Hashed into the signed payload, so altering the text invalidates the signature. |
| min_strength | AuthStrength | required | The minimum tier the signed response must satisfy: DEVICE_ACCESS or BIOMETRIC. There is no default. |
| location_constraint | LocationConstraint | optional | Optional geofence; evaluated on-device, only a coarse bucket returns. |
Response — IssueAuthChallengeResponse
| Field | Type | Notes | |
|---|---|---|---|
| challenge_id | string | optional | A 26-character ULID. The QR carries only this — no identity leaks on the wire. |
| qr_encoded | string | optional | The full Universal-Link URL to render as a QR (you supply the QR library). |
| expires_at | string | optional | ISO-8601 UTC; when the challenge stops being fetchable or signable. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no such ACTIVE user for the calling RP |
| MIN_STRENGTH_REQUIRED | min_strength was left UNSPECIFIED |
| DEVICE_INCAPABLE | the user’s device cannot meet the requested tier |
| INVALID_ARGUMENT | ttl_seconds out of range, or context too long |
| CHALLENGE_TOO_SOON | this user’s last challenge is still open and less than 15 seconds old; nothing was spent. Retry after auto_expires_at, or once the user has answered the open one. The newest open challenge’s own challenge_id again answers ALREADY_EXISTS instead, and spends a try as before |
| RATE_LIMIT_EXCEEDED | this user’s challenges are spent. Each challenge spends one; an approval gives them all back, while denied, dismissed and expired ones keep counting, and so does an approval refused for the wrong number or the wrong place. How often a user may be challenged between approvals is yours to limit. See Limits |
Examples
iss, err := client.IssueAuthChallenge(ctx, &identity.IssueAuthChallengeRequest{
OpaqueId: opaqueID,
TtlSeconds: 120, // [30,300]; 0 = 60s default
Context: []byte("Approve a $4,200 transfer to ...8842"),
MinStrength: identity.AuthStrength_AUTH_STRENGTH_BIOMETRIC, // level 2 — biometric, no passcode fallback
})
if err != nil { /* handle */ }
render(iss.QrEncoded) // draw the Universal-Link QR, or push it to the device// Same call, no SDK sugar (see CreateUser for the mTLS dial preamble).
iss, err := client.IssueAuthChallenge(ctx, &identity.IssueAuthChallengeRequest{
OpaqueId: opaqueID, TtlSeconds: 120,
Context: []byte("Approve login from Sofia"),
MinStrength: identity.AuthStrength_AUTH_STRENGTH_BIOMETRIC,
})
if err != nil { /* handle */ }
fmt.Println(iss.GetChallengeId(), iss.GetQrEncoded())iss = client.IssueAuthChallenge(identity_pb2.IssueAuthChallengeRequest(
opaque_id=opaque_id, ttl_seconds=120,
context=b"Approve a $4,200 transfer",
min_strength=identity_pb2.AUTH_STRENGTH_BIOMETRIC))
render(iss.qr_encoded)const iss = await client.issueAuthChallenge({
opaqueId, ttlSeconds: 120,
context: new TextEncoder().encode("Approve a transfer"),
minStrength: AuthStrength.BIOMETRIC,
});
render(iss.qrEncoded);Server-streaming IdentityService.AwaitChallenge Wait for the signed outcome 🔒 mTLS
Opens a server stream that yields the challenge’s terminal outcome — no polling, no webhook. The stream closes when the challenge reaches a terminal state.
Recv loop ends at EOF. It does not stay open after APPROVED / DENIED / EXPIRED.Read more than the verdict. auth_strength is the tier the device reached. On an approval it is at or above the min_strength you asked for; on any other status treat it as a report of what the device did rather than a promise about your floor. Alongside it, location_result is the bucket it reported: see Authorize an action. signature is not something you can check — the deployment verified it, and this surface returns neither the device’s public key nor the remaining payload fields you would need to rebuild the digest. Keep it as a record if you want one; do not present it as a check you performed.
Request — AwaitChallengeRequest
| Field | Type | Notes | |
|---|---|---|---|
| challenge_id | string | required | The 26-character ULID from IssueAuthChallenge. |
Response — AwaitChallengeFrame (stream)
| Field | Type | Notes | |
|---|---|---|---|
| status | AuthChallengeStatus | optional | PENDING → DELIVERED → a terminal APPROVED / DENIED / REJECTED / EXPIRED / SUPERSEDED / DISMISSED. |
| signature | bytes | optional | ECDSA-P256 (or hybrid) signature over the canonical payload, on APPROVED, DENIED and REJECTED. The deployment has already verified it; you cannot, because this surface returns neither the device key nor the rest of the payload. |
| expression | AuthChallengeExpression | optional | What the user said, as the device attested it. Separate from status: an approval can still end REJECTED. |
| decline_reason | DeclineReason | optional | Present iff DENIED, and only where the user gave a reason. |
| verification_failure | VerificationFailure | optional | Which check refused. Present iff REJECTED. |
| auth_strength | AuthStrength | optional | The tier the device reached. Treat it as a floor guarantee on APPROVED and nowhere else — on any other status it is a report of what the device did, not a promise about your min_strength. Unset on EXPIRED, SUPERSEDED and any non-terminal frame. |
| location_result | LocationResult | optional | Coarse bucket + accuracy, iff the device reported one. |
| emitted_at | string | optional | RFC 3339 Nano; when the server emitted the frame. |
Errors
| Reason | Raised when |
|---|---|
| CHALLENGE_NOT_FOUND | no challenge with that ID |
| CHALLENGE_ALREADY_TERMINAL | the challenge already reached a terminal state |
Examples
stream, err := client.AwaitChallenge(ctx,
&identity.AwaitChallengeRequest{ChallengeId: challengeID})
if err != nil { /* handle */ }
for {
frame, err := stream.Recv()
if err == io.EOF { break } // stream closes on a terminal state
if err != nil { /* handle */ }
switch frame.Status {
case identity.AuthChallengeStatus_AUTH_CHALLENGE_STATUS_APPROVED:
grant(frame.AuthStrength) // the tier reached, which may beat what you asked for
case identity.AuthChallengeStatus_AUTH_CHALLENGE_STATUS_DENIED:
log.Printf("denied: %v", frame.DeclineReason)
}
}stream, err := client.AwaitChallenge(ctx,
&identity.AwaitChallengeRequest{ChallengeId: challengeID})
if err != nil { /* handle */ }
for {
frame, err := stream.Recv()
if err == io.EOF { break }
if err != nil { /* handle */ }
log.Printf("status=%s strength=%s", frame.GetStatus(), frame.GetAuthStrength())
}for frame in client.AwaitChallenge(
identity_pb2.AwaitChallengeRequest(challenge_id=challenge_id)):
if frame.status == identity_pb2.AUTH_CHALLENGE_STATUS_APPROVED:
grant(strength=frame.auth_strength); break
if frame.status == identity_pb2.AUTH_CHALLENGE_STATUS_DENIED:
raise Denied(frame.decline_reason)for await (const frame of client.awaitChallenge({ challengeId })) {
if (frame.status === AuthChallengeStatus.APPROVED) { grant(frame.authStrength); break; }
if (frame.status === AuthChallengeStatus.DENIED) throw new Denied(frame.declineReason);
}Unary IdentityService.ListActiveChallenges List a user’s in-flight challenges 🔒 mTLS
Lists a user’s non-terminal challenges — PENDING (not yet picked up) or DELIVERED (fetched, awaiting a response). For a dashboard, or to avoid issuing a duplicate.
Request — ListActiveChallengesRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user whose active challenges to list. |
Response — ListActiveChallengesResponse
| Field | Type | Notes | |
|---|---|---|---|
| challenges | ActiveChallengeSummary[] | optional | Each carries challenge_id, status, echoed context, any geofence, and issued / expires / delivered timestamps. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
Examples
res, err := client.ListActiveChallenges(ctx,
&identity.ListActiveChallengesRequest{OpaqueId: opaqueID})
if err != nil { /* handle */ }
for _, c := range res.Challenges {
fmt.Println(c.ChallengeId, c.Status, c.ExpiresAt)
}Enrollment & recovery2 RPCs
Unary IdentityService.IssueEnrollmentInvite Invite a new device to enroll 🔒 mTLS
Mints a single-use invite that lets a user enroll a new device for an existing account. The relying party renders the QR; the user scans it in their Ydentifi app.
The invite is single-use and short-lived. The context is stored with the invite: the phone does not show it and the enrollment does not sign it. Before consent, the phone shows the host of the deployment the code points to. See Enroll & link devices.
Request — IssueEnrollmentInviteRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The ACTIVE user the device will be enrolled for. |
| ttl_seconds | uint32 | optional | How long the invite is scannable, in [60, 600]. Zero means the 300-second default. |
| context | bytes | optional | Optional string (≤256 B), stored with the invite. Not shown on the phone and not signed. |
Response — IssueEnrollmentInviteResponse
| Field | Type | Notes | |
|---|---|---|---|
| invite_token | bytes | optional | A 256-bit opaque token — the lookup key for the server-side invite record. |
| qr_payload | bytes | optional | The QR-renderable form (URL-safe base64 of the token). You draw the QR. |
| expires_at | string | optional | ISO-8601 UTC; when the invite stops being valid. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no such ACTIVE user for the calling RP |
| INVALID_ARGUMENT | ttl_seconds out of range, or context too long |
| RATE_LIMIT_EXCEEDED | this user’s limit for this call is spent; see Limits |
| MAX_DEVICES_REACHED | the user already has 3 active devices, the most allowed; revoke one, then invite again — see Enroll |
Examples
inv, err := client.IssueEnrollmentInvite(ctx, &identity.IssueEnrollmentInviteRequest{
OpaqueId: opaqueID,
TtlSeconds: 300, // [60,600]; 0 = 300 default
Context: []byte("Add a device for Acme Bank"),
})
if err != nil { /* handle */ }
render(inv.QrPayload) // show as a QR for the user's Ydentifi app to scanUnary IdentityService.IssueRecoveryInvite Recover an account on a new device 🔒 mTLS
Mints a recovery invite for a user who lost their device. Issuing it atomically revokes every currently-active device for that user, then lets them enroll fresh.
This is destructive by design: the moment the invite is issued, every active device for the user can no longer sign in or approve challenges — the whole point of lost-phone recovery. It does not replace the user’s TOTP secret: call IssueUserTotpEnrollment with reuse_existing false, or the lost phone’s codes stay valid. Use IssueEnrollmentInvite to add a device without revoking the others.
Request — IssueRecoveryInviteRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The ACTIVE user being recovered. All their active devices are revoked as the invite is created. |
| ttl_seconds | uint32 | optional | Scannable window in [60, 600]. Zero means the 300-second default. |
| context | bytes | optional | Optional string (≤256 B), stored with the invite. Not shown on the phone and not signed. |
Response — IssueRecoveryInviteResponse
| Field | Type | Notes | |
|---|---|---|---|
| invite_token | bytes | optional | A 256-bit opaque token the new device presents to claim the invite. |
| qr_payload | bytes | optional | The QR-renderable form (URL-safe base64 of the token). |
| expires_at | string | optional | ISO-8601 UTC; when the invite stops being valid. |
| revoked_device_count | uint32 | optional | How many active devices the cascade revoked (0 if the user had none). |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no such ACTIVE user for the calling RP |
| INVALID_ARGUMENT | ttl_seconds out of range, or context too long |
| RATE_LIMIT_EXCEEDED | this user’s limit for this call is spent; see Limits |
Examples
inv, err := client.IssueRecoveryInvite(ctx, &identity.IssueRecoveryInviteRequest{
OpaqueId: opaqueID,
TtlSeconds: 300,
Context: []byte("Recover your account on a new device"),
})
if err != nil { /* handle */ }
// Issuing the invite ALREADY revoked every active device for this user.
log.Printf("%d active devices revoked", inv.RevokedDeviceCount)
render(inv.QrPayload)TOTP2 RPCs
Unary IdentityService.IssueUserTotpEnrollment Enroll a TOTP secret 🔒 mTLS
Mints (or re-issues) a user’s time-based one-time-password secret and returns it three ways: a ready QR image, the raw otpauth:// URL, and the Base32 secret for manual entry. The Ydentifi app can hold it, or any third-party authenticator.
secret_base32 is returned in-band — TLS is the confidentiality boundary. Never log it or persist it in plaintext; hand it to the user and forget it. The Ydentifi app bundles TOTP with anti-phishing hardening, but the same secret works in any RFC-6238 authenticator via totp_url.
Request — IssueUserTotpEnrollmentRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user to enroll. |
| reuse_existing | bool | optional | True re-issues the current secret; false mints a fresh one, and every copy of the old one stops giving valid codes. Removing a device never does this. Only false unlocks codes locked by TOTP_LOCKED. |
Response — IssueUserTotpEnrollmentResponse
| Field | Type | Notes | |
|---|---|---|---|
| qr_code_png_base64 | string | optional | A 512×512 PNG of the enrollment QR, base64-encoded. |
| totp_url | string | optional | The raw RFC-6238 otpauth:// URL; edit the label to add a username. |
| secret_base32 | string | optional | The Base32 secret for manual entry. Sensitive — see the gotcha. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
| TOTP_SECRET_KEY_UNAVAILABLE | the server’s TOTP encryption key was removed by the operator |
| RATE_LIMIT_EXCEEDED | this user’s limit for this call is spent; see Limits |
Examples
inv, err := client.IssueUserTotpEnrollment(ctx, &identity.IssueUserTotpEnrollmentRequest{
OpaqueId: opaqueID,
ReuseExisting: false, // false mints a fresh secret; true re-issues the current one
})
if err != nil { /* handle */ }
// inv.TotpUrl is the otpauth:// URL; inv.QrCodePngBase64 is a ready 512x512 QR.
// inv.SecretBase32 is sensitive — never log or persist it in plaintext.
render(inv.QrCodePngBase64)Unary IdentityService.VerifyUserTotp Verify a 6-digit code 🔒 mTLS
Checks a user-entered TOTP code against their secret, within an optional clock-drift window.
Each code is accepted once. A code already accepted answers valid: false and counts as a wrong code, so never retry a call with the same code: after a timeout, ask the user for the next code. Treat RATE_LIMIT_EXCEEDED as a signal to back off, not to retry. Read the typed reason rather than the message text.
Request — VerifyUserTotpRequest
| Field | Type | Notes | |
|---|---|---|---|
| opaque_id | string | required | The user whose code to verify. |
| totp_code | string | required | The 6-digit code the user entered (RFC 6238). |
| skew | TotpSkew | optional | Clock-drift tolerance. Default is ±1 step (30 s); TOTP_SKEW_0 is ±0; at most ±2 steps (60 s). Wider is refused INVALID_ARGUMENT. |
Response — VerifyUserTotpResponse
| Field | Type | Notes | |
|---|---|---|---|
| valid | bool | optional | Whether the code matched the user’s secret and is newer than the last code accepted for the user. A used code, or an older one, is false. |
Errors
| Reason | Raised when |
|---|---|
| USER_NOT_FOUND | no user with that opaque ID |
| TOTP_NOT_ENROLLED | the user has no TOTP secret |
| RATE_LIMIT_EXCEEDED | too many verification attempts for this user; see Limits |
| TOTP_SECRET_KEY_UNAVAILABLE | the server’s TOTP encryption key was removed |
| TOTP_LOCKED | the user’s codes are locked after 100 wrong codes in a row; every code is refused, a correct one too, until IssueUserTotpEnrollment(reuse_existing=false) issues a new secret |
Examples
res, err := client.VerifyUserTotp(ctx, &identity.VerifyUserTotpRequest{
OpaqueId: opaqueID,
TotpCode: "492817",
})
if err != nil {
// Typed reason lives in the error details — read it, don't parse strings.
if d := identity.ErrorDetailFrom(err); d != nil {
switch d.Reason {
case identity.ErrorReason_RATE_LIMIT_EXCEEDED: // back off
case identity.ErrorReason_TOTP_NOT_ENROLLED: // prompt enrollment
}
}
return err
}
if !res.Valid { /* wrong code */ }try:
res = client.VerifyUserTotp(identity_pb2.VerifyUserTotpRequest(
opaque_id=opaque_id, totp_code="492817"))
except grpc.RpcError as e:
detail = error_detail_from(e) # reads ErrorDetail from status
if detail.reason == identity_pb2.RATE_LIMIT_EXCEEDED:
backoff()
raise
ok = res.validconst res = await client.verifyUserTotp({ opaqueId, totpCode: "492817" })
.catch((e) => {
const d = errorDetailFrom(e);
if (d?.reason === ErrorReason.RATE_LIMIT_EXCEEDED) backoff();
throw e;
});
const ok = res.valid;Certificates3 RPCs
Unary IdentityService.GetCertificateExpiry Read your certificate’s validity 🔒 mTLS
Returns the issuance, expiry, and fingerprint of the relying party’s currently-pinned mTLS certificate, plus its stored next-rotation TTL preference. Use it to drive your own expiry warnings.
There is no automatic client-cert renewal by design — rotation stays a deliberate, operator-driven act. Poll this to warn well before expires_at, then rotate. See Certificates & PKI.
Request — GetCertificateExpiryRequest
No fields — the relying party is identified by its mTLS certificate.
Response — GetCertificateExpiryResponse
| Field | Type | Notes | |
|---|---|---|---|
| issued_at | string | optional | RFC 3339 Nano. Empty if the RP has no cert yet. |
| expires_at | string | optional | RFC 3339 Nano. When the pinned cert expires. |
| fingerprint | string | optional | Hex SHA-256 over the cert DER — the value the server pins. Echo it to confirm a rotation landed. |
| cert_ttl_days | int32 | optional | Your stored next-rotation TTL preference, in days (defaults to 90 if never set). |
Errors
No domain-specific errors — only the shared transport reasons apply.
Examples
exp, err := client.GetCertificateExpiry(ctx, &identity.GetCertificateExpiryRequest{})
if err != nil { /* handle */ }
fmt.Println(exp.ExpiresAt, exp.Fingerprint, exp.CertTtlDays)
// Drive your own "cert expires in N days" warning off exp.ExpiresAt.Unary IdentityService.SetCertificateTTL Set your next-rotation lifetime 🔒 mTLS
Stores the lifetime, in days, that the next RotateCertificate will request. Does not change the current certificate.
Request — SetCertificateTTLRequest
| Field | Type | Notes | |
|---|---|---|---|
| cert_ttl_days | int32 | required | The desired lifetime in days for your next rotation. |
Response — SetCertificateTTLResponse
| Field | Type | Notes | |
|---|---|---|---|
| cert_ttl_days | int32 | optional | Echo of the value actually stored (the server may clamp it). |
Errors
| Reason | Raised when |
|---|---|
| INVALID_ARGUMENT | cert_ttl_days is outside the allowed range |
Examples
res, err := client.SetCertificateTTL(ctx, &identity.SetCertificateTTLRequest{
CertTtlDays: 730, // preference applied to your NEXT rotation
})
if err != nil { /* handle */ }
fmt.Println("stored ttl days:", res.CertTtlDays) // echoed backUnary IdentityService.RotateCertificate Rotate to a fresh certificate 🔒 mTLS
Issues a new mTLS certificate from a CSR you supply, and returns it with a grace window during which the old certificate is still accepted — so you can hot-reload without downtime. If the response never reaches you, send the same CSR again while the window is open: you get back the same certificate rather than a second one, and the certificate you are still presenting keeps working until you do.
Generate the new keypair yourself — the private key never leaves you. Install cert_pem, then reload your listeners within grace_period_seconds; the old certificate keeps working until the window closes, so connections never drop. Lost the response? Re-send the identical CSR, not a fresh one — a fresh CSR starts a second rotation and spends another of your daily allowance.
Request — RotateCertificateRequest
| Field | Type | Notes | |
|---|---|---|---|
| csr_pem | bytes | required | A PEM-encoded PKCS#10 CSR for a keypair you generated. |
| immediate_cutover | bool | optional | Retires the old certificate the moment the new one is stored, leaving no window. Set it when the key you are replacing is compromised. It costs you the retry above: once the old certificate stops being accepted, a lost response leaves you nothing to connect with, and recovery has to come from your operator. A CSR carrying the key already pinned is refused when this is set. |
Response — RotateCertificateResponse
| Field | Type | Notes | |
|---|---|---|---|
| cert_pem | bytes | optional | The newly-issued PEM certificate to install. |
| issued_at | string | optional | RFC 3339 Nano; the new cert’s issuance. |
| expires_at | string | optional | RFC 3339 Nano; the new cert’s expiry. |
| fingerprint | string | optional | Hex SHA-256 of the new cert. |
| grace_period_seconds | int32 | optional | How long the OLD cert stays accepted — your hot-reload window. |
Errors
| Reason | Raised when |
|---|---|
| INVALID_ARGUMENT | the CSR is malformed or its key is unacceptable |
Examples
res, err := client.RotateCertificate(ctx, &identity.RotateCertificateRequest{
CsrPem: csrPEM, // you generate a fresh keypair + CSR
})
if err != nil { /* handle */ }
install(res.CertPem)
// The OLD cert stays valid for res.GracePeriodSeconds — hot-reload
// your listeners inside that window to avoid dropping connections.