Authentication API
This content is not available in your language yet.
This page documents every operation in the authentication flow. See Authentication for the conceptual overview (JWT structure, x-huat-platform header, token lifecycle) — this page focuses on exact arguments, return shapes, and examples.
Every request in this reference — authenticated or not — must include the x-huat-platform: customer header. Requests without it are rejected before they reach any resolver.
Most operations on this page require no prior authentication — they are how a caller obtains a JWT in the first place. Each operation below states its actual auth requirement explicitly.
Signing in
Section titled “Signing in”Authenticates a user with email/phone + password and returns an access/refresh token pair.
Auth required: None. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | SignInInput! | Yes |
SignInInput:
| Field | Type | Notes |
|---|---|---|
email | String | Provide either email or phoneNumber |
phoneNumber | String | Provide either email or phoneNumber |
password | String! | Required |
Returns: AccessToken!
expiresIn and refreshExpiresIn are absolute expiry times as Unix epoch seconds (the token’s exp claim), not durations. Compare them with the current time; do not treat them as “seconds from now”.
| Field | Type |
|---|---|
accessToken | String! |
expiresIn | Float! |
refreshToken | String! |
refreshExpiresIn | Float! |
Example request:
mutation Login($input: SignInInput!) { login(input: $input) { accessToken expiresIn refreshToken refreshExpiresIn }}{ "input": { "password": "correct-horse-battery-staple" }}curl https://api.wetel.dev/graphql \ -H "Content-Type: application/json" \ -H "x-huat-platform: customer" \ -d '{"query":"mutation Login($input: SignInInput!) { login(input: $input) { accessToken expiresIn refreshToken refreshExpiresIn } }","variables":{"input":{"email":"[email protected]","password":"correct-horse-battery-staple"}}}'Example response:
{ "data": { "login": { "accessToken": "<YOUR_API_KEY>", "expiresIn": 1791500000, "refreshToken": "<YOUR_API_KEY>", "refreshExpiresIn": 1794090000 } }}refreshToken
Section titled “refreshToken”Exchanges a still-valid refresh token for a new access/refresh token pair.
Auth required: None (the refresh token itself is the credential).
Arguments:
| Argument | Type | Required |
|---|---|---|
input | RefreshAccessTokenInput! | Yes |
RefreshAccessTokenInput:
| Field | Type | Notes |
|---|---|---|
refreshToken | String! | Must match the wallet/session that originally issued it |
Returns: AccessToken! (same shape as login)
Example:
mutation RefreshToken($input: RefreshAccessTokenInput!) { refreshToken(input: $input) { accessToken expiresIn refreshToken refreshExpiresIn }}{ "input": { "refreshToken": "<YOUR_API_KEY>" } }Signing in with two-factor support
Section titled “Signing in with two-factor support”signIn
Section titled “signIn”Authenticates a user with email/phone and password, with built-in two-factor authentication support. Read the status field of the result to decide what to do next.
Auth required: None. Rate-limited to 10 calls per minute per client IP (a separate bucket from login), plus a per-account lockout after repeated failures.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | SignInInput! | Yes |
SignInInput:
| Field | Type | Notes |
|---|---|---|
email | String | Provide either email or phoneNumber |
phoneNumber | String | Provide either email or phoneNumber |
password | String! | Required |
Returns: SignInResult!
| Field | Type | Notes |
|---|---|---|
status | SignInStatus! | SIGNED_IN, TWO_FACTOR_REQUIRED, or TWO_FACTOR_ENROLMENT_REQUIRED |
token | AccessToken | The session tokens. Set only when status is SIGNED_IN. |
challengeId | String | Opaque, single-use id for the two-factor step. Set only for the two-factor statuses. |
challengeExpiresAt | Float | Absolute expiry of challengeId as Unix epoch seconds. Set only for the two-factor statuses. |
SignInStatus values:
| Value | Next step |
|---|---|
SIGNED_IN | Done — token holds the session (same shape as login). |
TWO_FACTOR_REQUIRED | Call completeSignInWithTwoFactor(challengeId, code) with the current authenticator code. |
TWO_FACTOR_ENROLMENT_REQUIRED | The workspace requires 2FA and this account has none. Call generateTwoFactorForSignIn(challengeId) for the QR code, then completeSignInWithTwoFactor(challengeId, code). |
A wrong password is not a SignInResult — it is the same BAD_REQUEST "Invalid username / password!" as login. challengeId is single-use, valid for 5 minutes, and expires after 5 wrong codes.
Example request:
mutation SignIn($input: SignInInput!) { signIn(input: $input) { status challengeId challengeExpiresAt token { accessToken expiresIn refreshToken refreshExpiresIn } }}{ "input": { "email": "<EMAIL>", "password": "<PASSWORD>" }}Note —
loginand two-factor accounts:loginrefuses any account that has two-factor authentication turned on (or whose workspace requires it) withFORBIDDENandextensions.originalError.codeTWO_FACTOR_REQUIREDorTWO_FACTOR_ENROLMENT_REQUIRED, after a correct password. Integrations with such accounts must usesignIn+completeSignInWithTwoFactorinstead. New clients should usesignInfor all accounts.
completeSignInWithTwoFactor
Section titled “completeSignInWithTwoFactor”Second step of signIn: submits the authenticator code to complete a two-factor sign-in and returns session tokens.
Auth required: None. Rate-limited to 10 calls per 5 minutes per client IP AND per challenge.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
challengeId | String! | Yes | From signIn. |
code | String! | Yes | The current 6-digit authenticator app code. |
Returns: AccessToken!
Same shape as login — accessToken, expiresIn, refreshToken, refreshExpiresIn.
For a TWO_FACTOR_ENROLMENT_REQUIRED challenge, success also turns two-factor on for the account.
Single use. Every failure (wrong, reused or expired code; unknown or used challenge; account locked or suspended) returns BAD_REQUEST "Invalid or expired sign-in code.". One exception: completing an enrolment challenge before calling generateTwoFactorForSignIn returns a BAD_REQUEST telling you to call it first.
Wrong codes count towards the same per-account lockout as wrong passwords. A challenge dies after 5 wrong codes — start again with signIn.
Example request:
mutation CompleteSignInWithTwoFactor($challengeId: String!, $code: String!) { completeSignInWithTwoFactor(challengeId: $challengeId, code: $code) { accessToken expiresIn refreshToken refreshExpiresIn }}{ "challengeId": "<CHALLENGE_ID>", "code": "<6_DIGIT_CODE>"}generateTwoFactorForSignIn
Section titled “generateTwoFactorForSignIn”Two-factor set-up during sign-in, only for a challengeId with status TWO_FACTOR_ENROLMENT_REQUIRED. Returns the secret and a QR code for the authenticator app.
Auth required: None. Rate-limited to 5 calls per 5 minutes per client IP AND per challenge.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
challengeId | String! | Yes | From signIn. |
Returns: TwoFactorInfo!
| Field | Type | Notes |
|---|---|---|
secret | String! | The TOTP secret. Bound to the account only when completeSignInWithTwoFactor proves a code. |
output | String! | A QR code data URL for the authenticator app. |
Calling it again on the same challenge replaces the pending secret. Any other or expired challenge returns BAD_REQUEST "Invalid or expired sign-in code.".
Example request:
mutation GenerateTwoFactorForSignIn($challengeId: String!) { generateTwoFactorForSignIn(challengeId: $challengeId) { secret output }}{ "challengeId": "<CHALLENGE_ID>"}Session revocation
Section titled “Session revocation”signOutEverywhere
Section titled “signOutEverywhere”Signs the current user out of every session on every device, including the current one.
Auth required: Yes. Rate-limited to 5 calls per minute per user.
Arguments: None.
Returns: Boolean! — always true.
Every access and refresh token issued to the user so far is refused from the next HTTP request on (UNAUTHENTICATED). refreshToken with an old refresh token fails. A new WebSocket connection with an old token is refused; a socket already open is not closed until it reconnects.
The client should clear its stored tokens and sign in again. Changing the password (changePassword) or resetting it (forgotPasswordWithOTP) does the same automatically.
Example request:
mutation { signOutEverywhere}revokeUserSessions
Section titled “revokeUserSessions”Workspace OWNER only: signs one user of your workspace out of every session on every device.
Auth required: Yes (workspace owner). Rate-limited to 20 calls per minute per user.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
userId | Int! | Yes | The user’s numeric id. |
Returns: Boolean! — always true.
The workspace is always the caller’s own (from the token), never an argument. The target user’s existing access and refresh tokens are refused from their next HTTP request or new WebSocket connection on; a socket already open is not closed until it reconnects. The user can sign in again normally — to keep someone out, also change or reset their password.
Errors: NOT_FOUND for a user of another workspace or an unknown id. FORBIDDEN for a non-owner.
Example request:
mutation RevokeUserSessions($userId: Int!) { revokeUserSessions(userId: $userId)}{ "userId": <USER_ID>}Workspace invites
Section titled “Workspace invites”inviteTenantUser
Section titled “inviteTenantUser”Workspace OWNER only: invites an email address to join your workspace.
Auth required: Yes (workspace owner). Rate-limited to 20 calls per hour per user.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
email | String! | Yes | Address to invite. |
role | TenantRole | No | Defaults to MEMBER. Only MEMBER is accepted; OWNER is a BAD_REQUEST. |
Returns: TenantInvite!
| Field | Type | Notes |
|---|---|---|
id | Int! | Invite id. |
email | String! | The invited address (lower-case). |
role | TenantRole! | Role the account gets on accept. |
status | TenantInviteStatus! | PENDING, ACCEPTED, REVOKED, or EXPIRED. |
expiresAt | DateTime! | When the current code stops working. |
invitedByUserId | Int! | Owner who sent it. |
lastSentAt | DateTime! | When the current code was emailed. |
sendCount | Int! | 1 + resends. |
createdAt | DateTime! | When the invite was first created. |
updatedAt | DateTime! | Last change to the invite. |
acceptedAt | DateTime | When accepted (if ACCEPTED). |
acceptedUserId | Int | Account created by accepting (if ACCEPTED). |
revokedAt | DateTime | When revoked (if REVOKED). |
The invite code is emailed to the invitee and is never returned by the API. Codes are valid for 7 days by default.
Errors: CONFLICT if the address already has an account in your workspace or a pending invite (resend or revoke that one instead). BAD_REQUEST for an invalid address or when the workspace already has the maximum pending invites allowed (50 by default).
An address with an account in another workspace is invited normally, but its accept will fail (an account belongs to one workspace).
The email obeys the platform email switch, suppression list, and the workspace’s daily email cap. When email is off or capped, the invite is still created and can be resent later.
Example request:
mutation InviteTenantUser($email: String!, $role: TenantRole) { inviteTenantUser(email: $email, role: $role) { id email role status expiresAt lastSentAt sendCount }}{ "email": "<INVITEE_EMAIL>", "role": "MEMBER"}acceptTenantInvite
Section titled “acceptTenantInvite”Accepts a workspace invite with the code from the invite email, creates the account, and signs the new user in.
Auth required: None. Rate-limited to 5 calls per 15 minutes per client IP AND per code.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | AcceptTenantInviteInput! | Yes |
AcceptTenantInviteInput:
| Field | Type | Notes |
|---|---|---|
code | String! | The one-time code from the invite email. |
password | String! | Follows sign-up password rules (weak password is a BAD_REQUEST). |
Returns: AccessToken!
Same shape as login — accessToken, expiresIn, refreshToken, refreshExpiresIn.
Single use. Every other failure (wrong, expired, revoked or used code; suspended workspace; an address that already has an account anywhere) returns BAD_REQUEST "This invite code is not valid. Ask the workspace owner to send a new invite.".
When the workspace requires two-factor authentication (myTenant.requireTwoFactor), the account IS created and the invite spent, but no token is returned: the answer is FORBIDDEN with extensions.originalError.code TWO_FACTOR_ENROLMENT_REQUIRED. The new member signs in with signIn (same email and the password just chosen) to set up two-factor.
Example request:
mutation AcceptTenantInvite($input: AcceptTenantInviteInput!) { acceptTenantInvite(input: $input) { accessToken expiresIn refreshToken refreshExpiresIn }}{ "input": { "code": "<INVITE_CODE>", "password": "<PASSWORD>" }}resendTenantInvite
Section titled “resendTenantInvite”Workspace OWNER only: emails a new invite code for a pending invite. The previous code stops working at once and the 7-day expiry restarts.
Auth required: Yes (workspace owner). Rate-limited to 10 calls per minute per user.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
id | Int! | Yes | Invite id. |
Returns: TenantInvite!
A pending invite that has already expired is renewed.
Errors: NOT_FOUND for an id that is not an invite of your workspace. CONFLICT if the invite is ACCEPTED or REVOKED. BAD_REQUEST if it was sent less than a minute ago.
Example request:
mutation ResendTenantInvite($id: Int!) { resendTenantInvite(id: $id) { id status expiresAt lastSentAt sendCount }}{ "id": <INVITE_ID>}revokeTenantInvite
Section titled “revokeTenantInvite”Workspace OWNER only: revokes a pending invite. Its code stops working at once.
Auth required: Yes (workspace owner). Rate-limited to 30 calls per minute per user.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
id | Int! | Yes | Invite id. |
Returns: TenantInvite!
Revoking an already REVOKED invite returns it unchanged.
Errors: NOT_FOUND for an id that is not an invite of your workspace. CONFLICT if the invite was already ACCEPTED.
Example request:
mutation RevokeTenantInvite($id: Int!) { revokeTenantInvite(id: $id) { id status revokedAt }}{ "id": <INVITE_ID>}tenantInvites
Section titled “tenantInvites”Workspace OWNER only: lists invites of your workspace, newest first.
Auth required: Yes (workspace owner).
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
first | Int | No | Page size (default 20, max 100). |
after | String | No | A previous page’s pageInfo.endCursor. |
status | TenantInviteStatus | No | Only invites in this state. A PENDING invite past expiresAt counts as EXPIRED. |
Returns: TenantInviteConnection! (Relay forward pagination)
Never returns an invite code or its hash.
Example request:
query TenantInvites($first: Int, $after: String, $status: TenantInviteStatus) { tenantInvites(first: $first, after: $after, status: $status) { edges { node { id email role status expiresAt lastSentAt sendCount createdAt } } pageInfo { endCursor hasNextPage } }}{ "first": 20, "status": "PENDING"}Workspace security
Section titled “Workspace security”setRequireTwoFactorForTenant
Section titled “setRequireTwoFactorForTenant”Workspace OWNER only: sets the policy “every password sign-in to this workspace must pass two-factor authentication” (myTenant.requireTwoFactor).
Auth required: Yes (workspace owner). Rate-limited to 5 calls per 15 minutes per user.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
required | Boolean! | Yes | true = require two-factor at sign-in; false = optional. |
ownerCode | String | No | Your own current authenticator code. Required when your account has two-factor turned on (always the case when turning the policy on). |
Returns: Boolean! — the new value of the policy. Setting the current value again changes nothing.
When turned on:
- At their next password sign-in, members with two-factor are asked for a code and members without it are asked to set it up (
signInanswersTWO_FACTOR_ENROLMENT_REQUIRED;loginis refused withFORBIDDEN,extensions.originalError.codeTWO_FACTOR_ENROLMENT_REQUIRED;acceptTenantInvitecreates the account but answers the sameFORBIDDENinstead of signing in). - Sessions already open are not ended (use
revokeUserSessionsper user if needed).
Turning it on requires two-factor on your own account first (CONFLICT otherwise). FORBIDDEN for a non-owner or a missing/wrong ownerCode when your account has two-factor. Audited (TWO_FACTOR_POLICY_CHANGED).
Example request:
mutation SetRequireTwoFactorForTenant($required: Boolean!, $ownerCode: String) { setRequireTwoFactorForTenant(required: $required, ownerCode: $ownerCode)}{ "required": true, "ownerCode": "<YOUR_6_DIGIT_CODE>"}resetTenantUserTwoFactor
Section titled “resetTenantUserTwoFactor”Workspace OWNER only: recovery for a user who lost their authenticator. Turns their two-factor off and signs them out of every session.
Auth required: Yes (workspace owner). Rate-limited to 5 calls per 15 minutes per user.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
userId | Int! | Yes | The user whose two-factor is removed. |
ownerCode | String | No | Your own current authenticator code. Required when your account has two-factor turned on. |
Returns: TenantUser!
The target user’s tokens are refused from their next request. Their next sign-in is plain, or asks them to set up two-factor when the workspace requires it (requireTwoFactor). A user who has no two-factor is returned unchanged.
Errors: BAD_REQUEST for your own account (use unbindTwoFactor). NOT_FOUND for a user not in your workspace. FORBIDDEN for a non-owner or a missing/wrong ownerCode. Audited (TWO_FACTOR_RESET_BY_OWNER).
Example request:
mutation ResetTenantUserTwoFactor($userId: Int!, $ownerCode: String) { resetTenantUserTwoFactor(userId: $userId, ownerCode: $ownerCode) { id email role }}{ "userId": <USER_ID>, "ownerCode": "<YOUR_6_DIGIT_CODE>"}workspaceSecurityEvents
Section titled “workspaceSecurityEvents”Workspace OWNER only: the security event log of your workspace, newest first.
Auth required: Yes (workspace owner). FORBIDDEN for a member (members have mySignInHistory for their own account).
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
first | Int | No | Page size (default 20, max 100). |
after | String | No | A previous page’s pageInfo.endCursor (with the same filters). |
events | [SecurityEventType!] | No | Only these event types. Omit or empty for all. ACCOUNT_UPDATED is listed only when explicitly requested. |
userId | Int | No | Only events about or by this user of your workspace. |
since | DateTime | No | Only events at or after this time (inclusive). |
until | DateTime | No | Only events at or before this time (inclusive). |
Returns: WorkspaceSecurityEventConnection! (Relay forward pagination)
Each event includes:
| Field | Type | Notes |
|---|---|---|
id | Int! | Audit row id. |
event | SecurityEventType! | What happened. |
createdAt | DateTime! | When recorded. |
actorUserId | Int | Who performed the action. Null for sign-in attempts and password reset requests. |
actorName | String | Actor’s display name at the time of the event. |
subjectUserId | Int | The user the event is about. |
subjectName | String | Subject’s current display name (while still in your workspace). |
method | SignInMethod | For SIGN_IN_SUCCEEDED only. |
failureReason | SignInFailureReason | For SIGN_IN_FAILED only. |
newRole | TenantRole | For ROLE_CHANGED only. |
inviteId | Int | For invite events only. |
Never returns an IP address, device, password, code or email address. Failed attempts for addresses with no account are not listed. Events are recorded from the release that added each of them.
Example request:
query WorkspaceSecurityEvents( $first: Int $after: String $events: [SecurityEventType!] $userId: Int $since: DateTime $until: DateTime) { workspaceSecurityEvents( first: $first after: $after events: $events userId: $userId since: $since until: $until ) { edges { node { id event createdAt actorUserId actorName subjectUserId subjectName method failureReason newRole inviteId } } pageInfo { endCursor hasNextPage } }}{ "first": 50, "userId": <USER_ID>, "since": "2026-10-01T00:00:00Z"}mySignInHistory
Section titled “mySignInHistory”The caller’s own account security history, newest first.
Auth required: Yes.
Arguments:
| Argument | Type | Required | Notes |
|---|---|---|---|
first | Int | No | How many events (default 20, max 50). |
Returns: [SignInEventDto!]!
Includes: successful sign-ins (with method), failed sign-in attempts on this account (reason class only: BAD_CREDENTIALS, LOCKED, SUSPENDED), temporary sign-in locks (ACCOUNT_LOCKED), password changes, password reset requests and completions, sign-out-everywhere, and an owner revoking this user’s sessions.
Only events about the signed-in user’s own account in their current workspace; never another user’s. Failed attempts are listed even though the caller did not make them. No IP address, device, password or code is ever returned. Events are recorded from the release that added this query; earlier sign-ins are not listed. No paging cursor.
Example request:
query MySignInHistory($first: Int) { mySignInHistory(first: $first) { event createdAt method failureReason }}{ "first": 20}Email verification
Section titled “Email verification”Available on staging; production rollout pending. Enforcement is off by default everywhere: until the platform switches it on, nothing is refused and emailVerified: false blocks nothing.
A signed-in user proves they own their email address with a one-time code sent to it.
requestEmailVerification
Section titled “requestEmailVerification”mutation { requestEmailVerification}Auth required: Yes (dashboard JWT). Returns Boolean! and always answers true, whether a code was queued, the user is already verified or has no email, so the answer reveals nothing. The code (6 digits) is only ever sent by email, never returned. One email per code: asking again while a code is still valid sends nothing new. Rate limit: 3 calls per 15 minutes per user; over the limit the call is refused with a TOO_MANY_REQUESTS error (see Rate limit refusals).
confirmEmailVerification
Section titled “confirmEmailVerification”mutation { confirmEmailVerification(code: "123456")}Auth required: Yes (dashboard JWT). code: String! is the 6-digit code from the email. Returns true on success (and when the user is already verified). A code works once and expires (15 minutes by default); five wrong guesses against one code invalidate it. Every failure (wrong, expired, already used) answers the same BAD_REQUEST with the message Invalid code. Rate limit: 5 calls per 15 minutes per user.
User.emailVerified
Section titled “User.emailVerified”emailVerified: Boolean! on the current user. It is a plain boolean; the time and the code are never exposed.
What is refused while enforcement is on
Section titled “What is refused while enforcement is on”When the platform has enforcement on, an account whose email is not verified is refused with HTTP 403, extensions.code EMAIL_NOT_VERIFIED and the message Verify your email address first on exactly these operations:
publishWorkflow,rollbackWorkflowcreateNamedApiKey,generateApiKeyinviteTenantUser,resendTenantInvitehireAgentTemplate,bindAgentTemplateSlot,provideAgentTemplateSlotCredential
Nothing else is gated. Accounts created or linked through WebbyX One sign-in, and accounts created by accepting a workspace invite, count as verified. When enforcement is on, a new account created with register is sent its code at sign-up. The fix on EMAIL_NOT_VERIFIED is to call requestEmailVerification, then confirmEmailVerification, then retry; do not retry blindly.
Registering
Section titled “Registering”isRegisterable
Section titled “isRegisterable”Checks whether an email is available for registration.
Auth required: None. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
Arguments:
| Argument | Type | Required |
|---|---|---|
email | String! | Yes |
idNo | String | No |
idNo is deprecated and no longer supported: a request that sends it is rejected with an error. Identity numbers can no longer be checked through this mutation.
Returns: JSONObject!, currently { "email": true } when the email is free to register and { "email": false } when it is taken.
Example:
query IsRegisterable($email: String!) { isRegisterable(email: $email)}register
Section titled “register”Creates a new user account and returns an access/refresh token pair immediately.
Auth required: None. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | RegisterInput! | Yes |
token | ValidateSecuredTokenInput | No — defaults to null |
RegisterInput:
| Field | Type | Notes |
|---|---|---|
email | String! | Required |
password | String! | Required |
fullname | String! | Required |
phoneCode | String! | Required, e.g. "+1" |
phoneNumber | String! | Required |
fcmToken | String | Optional, for push notifications |
idNo | String | Optional |
idType | String | Optional |
referralCode | String | Optional |
ValidateSecuredTokenInput (optional, used when registration is gated behind an OTP/invite token):
| Field | Type | Notes |
|---|---|---|
token | String! | The secured token or secured OTP string |
contact | String | Contact info tied to the token (email/phone); optional for SECURED_TOKEN |
id | Float | Preferred when the token type is TIMED_OTP; use contact when the frontend has no id |
Returns: AccessToken!
A freshly registered user is automatically assigned a new tenant of their own (named "<fullname>'s Workspace") — myTenant returns this new tenant immediately, not null. If you’re calling register from a flow that expects to explicitly create or assign a tenant afterward, note that one already exists by the time this mutation returns; check myTenant first rather than assuming you need to provision one yourself.
Example:
mutation Register($input: RegisterInput!) { register(input: $input) { accessToken refreshToken expiresIn }}{ "input": { "password": "correct-horse-battery-staple", "fullname": "Jamie Rivera", "phoneCode": "+1", "phoneNumber": "5551234567" }}Login with WebbyX One
Section titled “Login with WebbyX One”These three mutations let a person log into the Wetel dashboard using their WebbyX One (IAM One) account — either instead of email/password on first contact, or in addition to it from account settings. This is purely additive: it never replaces or disables password (or any other) login for an account.
loginWithWebbyxOne
Section titled “loginWithWebbyxOne”Logs in — or, on a genuinely first-time WebbyX One identity, silently creates — a Wetel dashboard account via WebbyX One SSO.
ticket is the single-use ticket minted by WebbyX One’s own /sso/login step (a frontend concern — your own sign-in page calls that endpoint with the public X-Client-Id, the same way sdkVerifyWebbyxOneIdentity’s docs describe; this mutation only consumes the resulting ticket). The actual token exchange happens server-side on Wetel’s end.
Account/tenant resolution, in order:
- If this WebbyX One identity is already linked to a Wetel account (
UserIdentityEntity(provider: WEBBYX_ONE)), log into that account. - Otherwise, if the ticket’s verified email matches an existing Wetel account (any login method — password, or a different provider), the WebbyX One identity is auto-linked to that account, and its existing tenant is used. An existing tenant always wins — no new company-based tenant is created or attached in this case.
- Otherwise (genuinely no existing account at all), a new Wetel account is created, and it’s attached to a tenant keyed by the WebbyX One company: if another user from the same company has already logged in via WebbyX One before, they join that same tenant; if not, a new tenant is created. Multiple people from the same WebbyX One company end up sharing one Wetel tenant, the same way an invited teammate does today.
There is no confirmation/consent step on this first-time path — a successful ticket exchange for a brand-new identity creates the account and logs it in immediately.
Auth required: None. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
Arguments:
| Argument | Type | Required |
|---|---|---|
ticket | String! | Yes |
Returns: AccessToken! (same shape as login)
Example:
mutation LoginWithWebbyxOne($ticket: String!) { loginWithWebbyxOne(ticket: $ticket) { accessToken expiresIn refreshToken refreshExpiresIn }}{ "ticket": "tk_a1b2c3..." }myLinkedIdentities
Section titled “myLinkedIdentities”Lists every login method linked to the signed-in account, for a settings screen (“login methods”). Auth: JWT. It always includes PASSWORD (every account has one) alongside any linked SSO provider such as WEBBYX_ONE.
query { myLinkedIdentities { provider linkedAt }}Each entry exposes only provider and linkedAt; the provider’s own user id and email are never returned.
linkIdentity
Section titled “linkIdentity”Links an additional login provider to the currently-authenticated account — e.g. an existing password user adds WebbyX One from account settings.
providerToken is provider-specific: for provider: WEBBYX_ONE, it’s a ticket, the same shape loginWithWebbyxOne accepts. Only WEBBYX_ONE is implemented today — GOOGLE/GITHUB are reserved AuthProvider values for future providers and throw a not-implemented error if used. Idempotent for re-linking the same provider to the same account; refuses if the identity is already linked to a different Wetel account.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
provider | AuthProvider! | Yes |
providerToken | String! | Yes |
AuthProvider values: PASSWORD | WEBBYX_ONE | GOOGLE | GITHUB (only PASSWORD and WEBBYX_ONE are functional today).
Returns: Boolean!
Example:
mutation LinkIdentity($provider: AuthProvider!, $providerToken: String!) { linkIdentity(provider: $provider, providerToken: $providerToken)}{ "provider": "WEBBYX_ONE", "providerToken": "tk_a1b2c3..." }curl https://api.wetel.dev/graphql \ -H "Content-Type: application/json" \ -H "x-huat-platform: customer" \ -H "Authorization: Bearer <YOUR_API_KEY>" \ -d '{"query":"mutation LinkIdentity($provider: AuthProvider!, $providerToken: String!) { linkIdentity(provider: $provider, providerToken: $providerToken) }","variables":{"provider":"WEBBYX_ONE","providerToken":"tk_a1b2c3..."}}'unlinkIdentity
Section titled “unlinkIdentity”Removes a linked login provider from the currently-authenticated account. Refuses (throws) if this would leave the account with zero login methods — every account must always retain at least one.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
provider | AuthProvider! | Yes |
Returns: Boolean!
Example:
mutation UnlinkIdentity($provider: AuthProvider!) { unlinkIdentity(provider: $provider)}{ "provider": "WEBBYX_ONE" }setPrimaryBillingAccount
Section titled “setPrimaryBillingAccount”Sets which system bills the currently-authenticated account’s tenant — WEBBYX_ONE (WebbyX One’s own credit ledger) or WETEL (Wetel’s own billing; re-enables the tenant’s normal per-plan monthly LLM spend cap). This is explicit and user-set — never inferred from which login method was used for a given session, and never automatic just because a WebbyX One identity happens to be linked.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
billingAccount | PrimaryBillingAccountEnum! | Yes |
PrimaryBillingAccountEnum values: WEBBYX_ONE | WETEL.
Returns: TenantDto! (see Tenant API for the full shape — primaryBillingAccount reflects the value just set)
Example:
mutation SetPrimaryBillingAccount($billingAccount: PrimaryBillingAccountEnum!) { setPrimaryBillingAccount(billingAccount: $billingAccount) { id primaryBillingAccount }}{ "billingAccount": "WEBBYX_ONE" }curl https://api.wetel.dev/graphql \ -H "Content-Type: application/json" \ -H "x-huat-platform: customer" \ -H "Authorization: Bearer <YOUR_API_KEY>" \ -d '{"query":"mutation SetPrimaryBillingAccount($billingAccount: PrimaryBillingAccountEnum!) { setPrimaryBillingAccount(billingAccount: $billingAccount) { id primaryBillingAccount } }","variables":{"billingAccount":"WEBBYX_ONE"}}'Password reset
Section titled “Password reset”generateOTP
Section titled “generateOTP”Generates a one-time password for a given purpose (sign-in, sign-up, password reset, etc.).
Auth required: None. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | GenerateOTPInput! | Yes |
GenerateOTPInput:
| Field | Type | Notes |
|---|---|---|
email | String | Provide either email or phoneNumber |
phoneNumber | String | Provide either email or phoneNumber |
purpose | TokenPurpose! | One of SIGN_IN, SIGN_UP, RESET_PASSWORD, INVITATION, UPDATE_PROFILE |
Returns: Int! (an identifier for the generated OTP record — pass this or the matching contact info into validateSecuredToken / the OTP-based password reset)
As of 2026-09-08, a generated code is purpose-bound and lockout-protected: a code minted for one purpose can only be spent against the flow matching that same purpose (e.g. a RESET_PASSWORD code can’t be validated by anything other than the reset-password flow), and a code locks out after 5 wrong-code attempts. A locked-out, wrong, and unknown code all return the same generic validation error — a caller can’t distinguish which case they hit. Request a fresh OTP if validation keeps failing.
Example:
mutation GenerateOTP($input: GenerateOTPInput!) { generateOTP(input: $input)}resetUserPasswordWithUrl
Section titled “resetUserPasswordWithUrl”Sends a password-reset email containing a reset link/token.
Auth required: None. Always returns true regardless of whether the email exists, to avoid leaking which emails are registered. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | ResetPasswordEmailInput! | Yes |
ResetPasswordEmailInput:
| Field | Type | Notes |
|---|---|---|
email | String | Optional — omitting it still returns true |
purpose | TokenPurpose! | Typically RESET_PASSWORD |
Returns: Boolean!
Example:
mutation ResetPasswordEmail($input: ResetPasswordEmailInput!) { resetUserPasswordWithUrl(input: $input)}forgotPasswordWithOTP
Section titled “forgotPasswordWithOTP”Sets a new password using a previously validated OTP/secured token — used for the “forgot password” flow (no current password required).
Auth required: None. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled. The presented token must have been generated with purpose: RESET_PASSWORD — a token minted for a different purpose (e.g. SIGN_IN) is now rejected rather than accepted.
Arguments:
| Argument | Type | Required |
|---|---|---|
input | PasswordWithOTPInput! | Yes |
PasswordWithOTPInput:
| Field | Type | Notes |
|---|---|---|
newPassword | String! | Required |
token | ValidateSecuredTokenInput! | See shape under register above |
Returns: Boolean!
Example:
mutation ForgotPasswordWithOTP($input: PasswordWithOTPInput!) { forgotPasswordWithOTP(input: $input)}{ "input": { "newPassword": "new-correct-horse-battery-staple", }}changePassword
Section titled “changePassword”Changes the password of the signed-in user. Since 2026-10-21 the current password is required: a session token alone can no longer change the password.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
input | PasswordInput! | Yes |
PasswordInput:
| Field | Type | Notes |
|---|---|---|
currentPassword | String! | Required. The account’s current password, checked the same way as sign-in. |
newPassword | String! | Required. Must meet the password rules and must differ from the current password. |
Returns: Boolean!, true when the password was changed. (Before 2026-10-21 this returned false even on success.)
Errors:
- A wrong
currentPasswordfails withBAD_REQUESTand the messageCurrent password is incorrect. - A
newPasswordequal to the current one fails withBAD_REQUEST. - At most 5 calls per 15 minutes per user, successful or not. Over the limit the call fails with the message
Too Many Requests. - An account created through single sign-on has no password its owner knows. Set one with the forgotten-password flow instead (
generateOTP, thenforgotPasswordWithOTP).
Sessions already signed in stay signed in after the change.
Example:
mutation ChangePassword($input: PasswordInput!) { changePassword(input: $input)}{ "input": { "currentPassword": "old-correct-horse-battery-staple", "newPassword": "new-correct-horse-battery-staple" }}curl https://api.wetel.dev/graphql \ -H "Content-Type: application/json" \ -H "x-huat-platform: customer" \ -H "Authorization: Bearer <YOUR_ACCESS_TOKEN>" \ -d '{"query":"mutation ChangePassword($input: PasswordInput!) { changePassword(input: $input) }","variables":{"input":{"currentPassword":"old-correct-horse-battery-staple","newPassword":"new-correct-horse-battery-staple"}}}'Two-factor authentication
Section titled “Two-factor authentication”generateTwoFactor
Section titled “generateTwoFactor”Generates a new 2FA secret and QR-code payload for the current user to scan into an authenticator app, prior to binding it.
Auth required: JWT (AuthJwtGuard).
Arguments: None.
Returns: TwoFactorInfo!
| Field | Type | Notes |
|---|---|---|
secret | String! | The raw TOTP secret |
output | String! | QR-code-ready payload (e.g. otpauth:// URI) |
Example:
mutation GenerateTwoFactor { generateTwoFactor { secret output }}bindTwoFactor
Section titled “bindTwoFactor”Binds 2FA to the current user by confirming a code generated from the secret returned by generateTwoFactor.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
input | BindTwoFactorInput! | Yes |
BindTwoFactorInput:
| Field | Type | Notes |
|---|---|---|
secret | String! | The secret from generateTwoFactor |
code | String! | The current TOTP code generated from that secret |
Returns: Boolean!
Example:
mutation BindTwoFactor($input: BindTwoFactorInput!) { bindTwoFactor(input: $input)}{ "input": { "secret": "JBSWY3DPEHPK3PXP", "code": "482913" } }verifyTwoFactor
Section titled “verifyTwoFactor”Verifies a 2FA code. Per the schema’s own description, most integrations will not need to call this directly.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
code | String! | Yes |
Returns: Boolean!
Example:
mutation VerifyTwoFactor($code: String!) { verifyTwoFactor(code: $code)}{ "code": "482913" }unbindTwoFactor
Section titled “unbindTwoFactor”Removes 2FA from the current user’s account.
Auth required: JWT (AuthJwtGuard).
Arguments:
| Argument | Type | Required |
|---|---|---|
input | UnbindTwoFactorInput! | Yes |
UnbindTwoFactorInput:
| Field | Type | Notes |
|---|---|---|
code | String! | Current TOTP code, required to confirm the unbind |
Returns: Boolean!
Example:
mutation UnbindTwoFactor($input: UnbindTwoFactorInput!) { unbindTwoFactor(input: $input)}{ "input": { "code": "482913" } }Current user profile
Section titled “Current user profile”getAuthProfile
Section titled “getAuthProfile”Retrieves the profile of the currently authenticated user.
Auth required: JWT (AuthJwtGuard).
Arguments: None.
Returns: User (nullable)
| Field | Type | Notes |
|---|---|---|
id | Int! | |
email | String | |
fullname | String! | |
username | String | |
phoneCode | String | |
phoneNumber | String | |
idNo | String | |
idType | String | |
referralCode | String! | |
status | UserStatusType! | ACTIVE or SUSPENDED |
twoFactorEnabled | Boolean! | Derived from whether a 2FA secret is bound — the secret itself is never exposed |
tenantRole | TenantRole! | OWNER (created the workspace, or is the WebbyX One owner of its company) or MEMBER; read-only, and re-checked by the server on every owner-only call |
createdAt | DateTime! | |
updatedAt | DateTime! |
Example request:
query GetAuthProfile { getAuthProfile { id email fullname status twoFactorEnabled }}curl https://api.wetel.dev/graphql \ -H "Content-Type: application/json" \ -H "x-huat-platform: customer" \ -H "Authorization: Bearer <YOUR_API_KEY>" \ -d '{"query":"query { getAuthProfile { id email fullname status twoFactorEnabled } }"}'Example response:
{ "data": { "getAuthProfile": { "id": 42, "fullname": "Jamie Rivera", "status": "ACTIVE", "twoFactorEnabled": false } }}Once you have an accessToken, see Tenant for reading/managing your tenant and generating an API key for server-to-server calls, and Getting Started for the overall flow from signup to your first agent.