User-Service API Reference
Canister: user-service Candid Interface: user_service.didReconciled against the live .did: 2026-08-25 (bl-1404 — merged the former user-service-api.md comprehensive guide and user-service.md Candid quick-ref into this single page; method names/signatures below are arbitrated against src/user_service.did on main)
Router note: the production deployment fronts user-service with the separate user-service-router canister (sharding). Its routing surface is documented at user-service-router. The methods below are the user-service canister's own interface and work identically in standalone and routed modes.
Overview
The user-service canister provides user management, authentication, address management, self-custody verification, KYC/duplicate-prevention support, GDPR data-deletion, and COPPA parental-consent functionality for the Hello World DAO ecosystem.
Key Features
- Individual (user) registration and email verification
- Multi-method authentication (Email/Password, Internet Identity, OIDC, OAuth attestation)
- Address management with primary-address support
- Session token issue + refresh
- Self-custody wallet verification (Epic 2.2.3)
- GDPR data deletion (Story 2.0.5) and COPPA parental consent (BL-012.3)
- Duplicate-account prevention via ID-hash lifecycle (Story 2.0.7)
- Controller-only administrative + backup functions
Account creation is off-chain. See create_account — REMOVED below. Registration runs through
identity-servicePOST /register+ the@hello-world-co-op/authclient per ADR-020; user-service does not create accounts.
Authentication & Access Levels
Methods authenticate the caller via ic_cdk::caller(). Some settings-write methods accept an opt principal "caller_principal" argument used only for oracle-bridge-forwarded calls (AUTH-007.1); direct Internet Identity calls pass null.
- Public: any caller
- Controller: caller must be in the canister CONTROLLERS list
- Bridge/admin-gated: guarded by the oracle-bridge service principal (
require_oracle_bridge) oris_admin(e.g.confirm_payment,admin_review_kyc)
Core Data Types
IndividualRecord
⚠️ The live record stores PII encrypted (
*_encrypted : blob), not as plaintexttext. Earlier editions of this page (and the old quick-ref) showed a plaintextemail : textshape that no longer matches the deployed WASM. Authoritative shape (user_service.did):
type IndividualRecord = record {
id : text;
// Encrypted PII (Epic 7)
email_encrypted : blob;
first_name_encrypted : blob;
last_name_encrypted : blob;
email_hash : text; // SHA-256 hex secondary index
encryption_key_id : text;
encryption_type : EncryptionType;
// Recovery-key envelope (Epic 2.5)
encrypted_recovery_key : blob;
password_salt : blob;
verification_token : text;
verification_code : text;
verified : bool;
submitted_at : nat64;
verified_at : opt nat64;
code_expires_at : nat64;
ip_hash : opt text;
ii_principal : opt principal;
auth_methods : vec AuthMethod;
preferred_auth_method : opt AuthMethodType;
// Membership-mint prerequisites (Epic 2.1.6)
membership_token_id : opt nat64;
kyc_verified : bool;
payment_received : bool;
tos_accepted_at : opt nat64;
// Self-custody (Epic 2.2.3)
self_custody_verified : bool;
self_custody_verified_at : opt nat64;
self_custody_expires_at : opt nat64;
// Preferences / profile / notifications (Epic FOS-5.1)
preferences : opt UserPreferences;
profile : opt Profile;
notification_preferences : opt NotificationPreferences;
// COPPA (BL-011.2 / BL-012.3)
dob_encrypted : blob;
is_age_verified_18_plus : bool;
parental_consent_status : opt ParentalConsentStatus;
parent_email_hash : opt text;
consent_token : opt text;
consent_requested_at : opt nat64;
consent_received_at : opt nat64;
};Admin-safe view.
IndividualAdminView(returned by the*_adminmethods) excludesverification_token,verification_code,password_salt,encrypted_recovery_key,ip_hash, anddob_encrypted(FOS-5.6.10 — sensitive data never returned in API responses).
AuthMethod & Credential
type AuthMethod = record {
method_type : AuthMethodType;
identifier : text;
credential : opt Credential;
verified : bool;
linked_at : nat64;
last_used : opt nat64;
};
type Credential = variant {
PasswordHash : blob;
OidcToken : OidcTokenData;
Principal : principal;
};
type AuthMethodType = variant {
EmailPassword; InternetIdentity; Google; Apple; Microsoft; GitHub; Discord;
};The
AuthMethodTypevariant enumerates every provider slot the type reserves; the shipped OAuth providers are Google and GitHub (others are reserved — see the FounderyOS OAuth hold).
AddressRequest / Address
type AddressType = variant { Home; Work; Billing; Shipping; Other : text };
type AddressRequest = record {
address_type : AddressType;
country : text; // required
state : opt text;
city : text; // required
postal_code : text; // required
street_address : opt text;
street_address2 : opt text;
is_primary : bool;
};
type Address = record {
id : text; // addr_{timestamp}{random}
individual_id : text;
address_type : AddressType;
country : text; state : opt text; city : text; postal_code : text;
street_address : opt text; street_address2 : opt text;
is_primary : bool;
created_at : nat64; updated_at : nat64;
};IndividualRequest / result types
type EncryptionType = variant { UserDerived; Temporary };
type IndividualRequest = record {
email_encrypted : text; // base64
first_name_encrypted : text;
last_name_encrypted : text;
email_hash : text; // SHA-256 hex
encryption_key_id : text;
encryption_type : EncryptionType;
email_plaintext_for_verification : text; // used only to send the code, not stored
};
type IndividualResult = record { success : bool; message : text; id : opt text };
type VerifyResult = record { success : bool; message : text };
type Stats = record { total_individuals : nat64; verified_individuals : nat64; pending_verifications : nat64 };Registration & Verification
submit_individual
Register a new individual with an optional initial address (legacy / interest-form path).
submit_individual : (IndividualRequest, opt AddressRequest) -> (IndividualResult);Access: Public. Returns IndividualResult (id = ind_{timestamp}{random} on success). Errors: "Email already registered".
const result = await userService.submit_individual(
{
email_encrypted: btoa("user@example.com"),
first_name_encrypted: btoa("John"),
last_name_encrypted: btoa("Doe"),
email_hash: sha256("user@example.com"),
encryption_key_id: "key_001",
encryption_type: { Temporary: null },
email_plaintext_for_verification: "user@example.com"
},
[] // no initial address; or [{ address_type:{Home:null}, country:"US", city:"…", postal_code:"…", is_primary:true, state:[], street_address:[], street_address2:[] }]
);register_email_password / register_email_password_verified
register_email_password : (RegisterEmailPasswordRequest) -> (variant { Ok : AuthResponse; Err : text });
register_email_password_verified : (RegisterEmailPasswordVerifiedRequest) -> (variant { Ok : AuthResponse; Err : text });register_email_password_verified (arch-007-3a-4 / ADR-014) is the off-chain-derivation variant: it accepts a pre-derived age_category : AgeCategory (Adult | Minor13To17 | Under13) instead of a raw DOB — the raw DOB never crosses the wire. Controller-gated. Under13 is rejected on apply (fail-closed COPPA).
verify_email
Verify email using the token from an email link.
verify_email : (text) -> (VerifyResult);verify_code
Verify email using the emailed code. Four parameters — (email, code, first_name, last_name) — the trailing names trigger the post-verification database sync (Story 2-5-2).
verify_code : (text, text, text, text) -> (VerifyResult);⚠️ Prior editions of this page documented a two-parameter
verify_code : (text, text). That signature is stale; the live canister takes four parameters.
resend_verification_code
resend_verification_code : (text) -> (VerifyResult); // (email)Authentication
authenticate_with_password
Authenticate with email + password. Returns an AuthResponse carrying access/refresh tokens and the recovery-key envelope + encrypted PII for client-side decryption (Epic 2.5).
authenticate_with_password : (AuthRequest, text, opt text, opt text, opt text) -> (AuthResponse);
// (AuthRequest{email,password}, device_fingerprint, ip_address?, timezone?, user_agent?)⚠️ Prior editions marked this method "commented out / pending auth_api update". It is live in the deployed canister.
authenticate_with_internet_identity
authenticate_with_internet_identity : (text, opt text, opt text, opt text) -> (AuthResponse);
// (device_fingerprint, ip_address?, timezone?, user_agent?) — uses ic_cdk::caller() for the II principalauthenticate_with_oidc / authenticate_with_oauth
authenticate_with_oidc : (OidcAuthRequest, opt text) -> (AuthResponse);
authenticate_with_oauth : (OAuthAttestationRequest, opt text) -> (AuthResponse);refresh_tokens
refresh_tokens : (text, text, opt text, opt text, opt text) -> (RefreshTokenResponse);
// (refresh_token, device_fingerprint, ip_address?, timezone?, user_agent?)AuthResponse.session_id is deprecated (kept for backward compatibility); use access_token / refresh_token.
Password Management
set_password : (text, SetPasswordRequest) -> (variant { Ok : null; Err : text });
initiate_password_reset : (text) -> (variant { Ok : text; Err : text }); // (email) — 15-min code
complete_password_reset : (text, text, text, text, text) -> (variant { Ok : text; Err : text });
complete_password_reset_simple : (text, text, text, text, text) -> (variant { Ok : text; Err : text });complete_password_reset* params: (email, reset_code, new_password, encrypted_recovery_key_base64, password_salt_base64). The _simple variant clears encrypted PII — the user must re-enter profile info after login.
type SetPasswordRequest = record {
current_password : opt text;
new_password : text;
encrypted_recovery_key : opt blob; // re-encrypted with the NEW password-derived key (Epic 2.5)
password_salt : opt blob;
};Off-chain password support (controller-only, called by oracle-bridge): get_user_id_by_email_hash, get_user_credentials_by_email_hash, get_password_hash_by_email_hash, update_password, update_password_hash.
Individual & Address Management
get_current_user : () -> (opt IndividualRecord) query; // by ic_cdk::caller() → ii_principal
get_user_by_principal : (principal) -> (opt IndividualRecord) query;
get_member_dob_by_principal : (principal) -> (variant { Ok : opt text; Err : text }) query; // controller-only (ADR-015)
delete_individual : (text) -> (variant { Ok : text; Err : text });
sever_individual : (text, SeveranceMode) -> (variant { Ok : SeveranceReport; Err : text }); // COPPA/GDPR erasure (bl-1102)
add_address : (text, AddressRequest) -> (variant { Ok : text; Err : text }); // (individual_id, …) → address_id
update_address : (text, AddressRequest) -> (variant { Ok : null; Err : text }); // (address_id, …)
delete_address : (text) -> (variant { Ok : null; Err : text });
set_primary_address : (text, text) -> (variant { Ok : null; Err : text }); // (individual_id, address_id)
get_addresses_for_individual : (text) -> (vec Address) query;
get_primary_address : (text) -> (opt Address) query;Address validation: country, city, postal_code required; setting is_primary: true unsets primary on the individual's other addresses. sever_individual's SeveranceReport deliberately carries no identifier of the severed subject — field names only.
Internet Identity Linking
link_ii_principal : (text, principal) -> (variant { Ok : null; Err : text }); // (user_id, ii_principal)
create_user_ii : (principal) -> (variant { Ok : text; Err : text }); // II-first minimal record → UUID (idempotent)
update_user_ii_profile : (record { user_id; email_encrypted; email_hash; display_name; dob_encrypted; is_age_verified_18_plus; parent_email_hash; requires_parental_consent }) -> (variant { Ok : null; Err : text }); // controller-only
get_linked_ii_principal : (text) -> (variant { Ok : opt principal; Err : text }) query; // (user_id)Removed methods.
unlink_ii_principal,relink_ii_to_account,get_ii_link_status, andget_user_by_ii_principalwere removed (uid-001-13d/13e — caller-free after the identity-gateway callsites were dropped). Earlier editions of the quick-ref documentedunlink_ii_principalandget_user_by_ii_principal; do not call them — they no longer exist. The II link/relink/unlink flow now targets the identity-gateway canister.
Self-Custody Verification (Epic 2.2.3)
update_self_custody_status : (text, bool, nat64) -> (variant { Ok : null; Err : text }); // (user_id, verified, timestamp)
check_self_custody_status : (text) -> (variant { Ok : SelfCustodyStatus; Err : text }) query; // Verified | Expired | NeverVerified
get_self_custody_status : (text) -> (variant { Ok : SelfCustodyStatusInfo; Err : text }) query; // detailed, for governanceGDPR Data Deletion (Story 2.0.5)
request_kyc_data_deletion : () -> (variant { Ok : null; Err : text }); // 30-day grace period
cancel_kyc_data_deletion : () -> (variant { Ok : null; Err : text });
get_deletion_request_status : () -> (variant { Ok : opt DeletionRequest; Err : text }) query;Duplicate-Account Prevention (Story 2.0.7)
claim_temporary_id_hash : (text, text, text) -> (variant { Ok : null; Err : text }); // (id_type, country, id_number) — 24h claim
check_duplicate_id_hash : (text, text, text) -> (variant { Ok : bool; Err : text }); // user-safe, prevents enumeration
promote_to_permanent_hash : (principal) -> (variant { Ok : null; Err : text }); // after KYC Verified (idempotent)Admin (controller-only) override path: propose_admin_override (2-of-3 multi-sig, proposer auto-approves), approve_admin_override (auto-executes at threshold), list_pending_overrides, get_override_request, get_id_hash_status, get_hash_lifecycle, verify_pepper_integrity.
Membership Minting (Epic 2.1.6)
check_and_mint_membership : (text) -> (variant { Ok : nat64; Err : text }); // (user_id) → ICRC-7 token ID
upgrade_membership_to_active : (text) -> (variant { Ok : nat64; Err : text });
retry_failed_mints : () -> (variant { Ok : nat32; Err : text }); // controller-only
get_mint_retry_queue : () -> (vec MintRetryEntry) query; // controller-only⚠️ bl-1093 caveat (do not treat as the live mint path). This mint block has no reachable in-fleet caller and carries a known reply-decode defect against membership's
variant { Ok : nat; Err : text }. The live registered-SBT mint bypasses user-service entirely (oracle-bridgeroutes/auth.ts→ membership directly). These methods ship with the canister per the ADR-020 pre-decommission checklist;upgrade_membership_to_activeadditionally errors by construction (short-payload argument mismatch). Documented for completeness, not as the current mint entry point.
create_account — REMOVED
⚠️ This method no longer exists.
create_accountwas removed fromuser-service(user-service#77). Account creation is handled off-chain byidentity-servicePOST /register+ the@hello-world-co-op/authclient, per ADR-020.user-servicedoes not create accounts, and the formerCreateAccountRequesttype documented in the old quick-ref is orphaned — do not use it. See the Registration Flow guide for the current path.
Statistics & Admin
get_stats : () -> (Stats) query;
list_individuals : () -> (variant { Ok : vec IndividualRecord; Err : text }) query; // controller-only
list_individuals_paginated : (nat64, nat64) -> (variant { Ok : PaginatedResult; Err : text }) query; // controller-only
list_individuals_paginated_admin : (nat64, nat64) -> (variant { Ok : PaginatedAdminResult; Err : text }) query; // admin-safe view
get_user_by_principal_admin : (principal) -> (opt IndividualAdminView) query;
get_user_by_id_admin : (text) -> (opt IndividualAdminView) query;
get_display_name : (text) -> (opt text) query; // controller-only (BL-028.2)Preferences / profile / notifications (Epic FOS-5.1) — self methods take an opt principal for bridge-forwarded writes: get_preferences, update_preferences, register_username, get_profile_by_username, update_profile, get_notification_preferences, update_notification_preferences; controller-only per-user variants: get_notification_preferences_for_user, update_notification_preferences_for_user, update_preferences_for_user, register_username_for_user, update_profile_for_user.
KYC / payment / consent: confirm_payment (oracle-bridge-gated; drives the membership mint relay — unauthenticated-reachable is prevented by require_oracle_bridge), kyc_webhook (mock-KYC sink), admin_review_kyc (is_admin), approve_parental_consent, admin_verify_email, send_parental_consent_email (BL-012.3).
Audit / config / backup: get_all_audit_entries (deprecated — use get_audit_entries_paginated), get_audit_entries_paginated, set_oracle_bridge_principal / get_oracle_bridge_principal, set_principals_canister / clear_principals_canister / get_principals_canister (arch-007-3a defense-in-depth gate + kill switch), set_membership_canister, set_dao_admin_canister / get_dao_admin_canister, set_oracle_bridge_url / get_oracle_bridge_url, set_oracle_public_key, initialize_signing_key / reset_signing_key / get_signing_public_key, export_state / import_state (controller-only; state contains GDPR-protected PII — encrypt backups at rest).
Dev/test-only (absent from --features prod builds): get_verification_code_debug, set_test_pepper, clear_all_individuals.
Completeness note. The live
user_service.didexposes ~100 methods. The sections above document the member- and integration-facing surface in detail and index the remaining administrative/internal methods by name and signature. The.didis the authoritative, exhaustive source — enumerate#[update]/#[query]in the Rust sources (or readuser_service.didonmain) for anything not shown here; the hand-maintained.didheader itself warns against treating it as a complete census.
Error Handling
All fallible methods return variant { Ok : T; Err : text }. Common errors:
| Error | Cause | Resolution |
|---|---|---|
Email already registered | Duplicate email_hash | Use a different email / password reset |
Invalid verification code | Wrong or expired code | Request a new code |
Individual not found | Invalid individual_id | Verify the ID exists |
Address not found | Invalid address_id | Verify the address ID |
Address does not belong to this individual | Ownership mismatch | Pass the owning individual_id |
Unauthorized | Caller not a controller / not the gated principal | Check the caller principal |
Duplicate ID detected | KYC ID hash already registered | Contact support |
Frontend Integration
import { Actor, HttpAgent } from '@dfinity/agent';
import { idlFactory } from './declarations/user-service';
const agent = new HttpAgent({ host: 'https://ic0.app' });
const userService = Actor.createActor(idlFactory, { agent, canisterId: 'your-canister-id' });
const stats = await userService.get_stats();Authentication is handled out-of-band by oracle-bridge sessions (cookie-based); inter-canister auth calls are no longer used (auth-service decommissioned 2026-04-11).
Related Documentation
- user-service-router — production routing/sharding surface
- Registration Flow
- Architecture: user-service sharding architecture (see the architecture section)