Skip to content

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.

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:

ArgumentTypeRequired
inputSignInInput!Yes

SignInInput:

FieldTypeNotes
emailStringProvide either email or phoneNumber
phoneNumberStringProvide either email or phoneNumber
passwordString!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”.

FieldType
accessTokenString!
expiresInFloat!
refreshTokenString!
refreshExpiresInFloat!

Example request:

mutation Login($input: SignInInput!) {
login(input: $input) {
accessToken
expiresIn
refreshToken
refreshExpiresIn
}
}
{
"input": {
"email": "[email protected]",
"password": "correct-horse-battery-staple"
}
}
Terminal window
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
}
}
}

Exchanges a still-valid refresh token for a new access/refresh token pair.

Auth required: None (the refresh token itself is the credential).

Arguments:

ArgumentTypeRequired
inputRefreshAccessTokenInput!Yes

RefreshAccessTokenInput:

FieldTypeNotes
refreshTokenString!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>" } }

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:

ArgumentTypeRequired
inputSignInInput!Yes

SignInInput:

FieldTypeNotes
emailStringProvide either email or phoneNumber
phoneNumberStringProvide either email or phoneNumber
passwordString!Required

Returns: SignInResult!

FieldTypeNotes
statusSignInStatus!SIGNED_IN, TWO_FACTOR_REQUIRED, or TWO_FACTOR_ENROLMENT_REQUIRED
tokenAccessTokenThe session tokens. Set only when status is SIGNED_IN.
challengeIdStringOpaque, single-use id for the two-factor step. Set only for the two-factor statuses.
challengeExpiresAtFloatAbsolute expiry of challengeId as Unix epoch seconds. Set only for the two-factor statuses.

SignInStatus values:

ValueNext step
SIGNED_INDone — token holds the session (same shape as login).
TWO_FACTOR_REQUIREDCall completeSignInWithTwoFactor(challengeId, code) with the current authenticator code.
TWO_FACTOR_ENROLMENT_REQUIREDThe 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 — login and two-factor accounts: login refuses any account that has two-factor authentication turned on (or whose workspace requires it) with FORBIDDEN and extensions.originalError.code TWO_FACTOR_REQUIRED or TWO_FACTOR_ENROLMENT_REQUIRED, after a correct password. Integrations with such accounts must use signIn + completeSignInWithTwoFactor instead. New clients should use signIn for all accounts.


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:

ArgumentTypeRequiredNotes
challengeIdString!YesFrom signIn.
codeString!YesThe 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>"
}

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:

ArgumentTypeRequiredNotes
challengeIdString!YesFrom signIn.

Returns: TwoFactorInfo!

FieldTypeNotes
secretString!The TOTP secret. Bound to the account only when completeSignInWithTwoFactor proves a code.
outputString!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>"
}

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
}

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:

ArgumentTypeRequiredNotes
userIdInt!YesThe 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 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:

ArgumentTypeRequiredNotes
emailString!YesAddress to invite.
roleTenantRoleNoDefaults to MEMBER. Only MEMBER is accepted; OWNER is a BAD_REQUEST.

Returns: TenantInvite!

FieldTypeNotes
idInt!Invite id.
emailString!The invited address (lower-case).
roleTenantRole!Role the account gets on accept.
statusTenantInviteStatus!PENDING, ACCEPTED, REVOKED, or EXPIRED.
expiresAtDateTime!When the current code stops working.
invitedByUserIdInt!Owner who sent it.
lastSentAtDateTime!When the current code was emailed.
sendCountInt!1 + resends.
createdAtDateTime!When the invite was first created.
updatedAtDateTime!Last change to the invite.
acceptedAtDateTimeWhen accepted (if ACCEPTED).
acceptedUserIdIntAccount created by accepting (if ACCEPTED).
revokedAtDateTimeWhen 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"
}

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:

ArgumentTypeRequired
inputAcceptTenantInviteInput!Yes

AcceptTenantInviteInput:

FieldTypeNotes
codeString!The one-time code from the invite email.
passwordString!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>"
}
}

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:

ArgumentTypeRequiredNotes
idInt!YesInvite 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>
}

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:

ArgumentTypeRequiredNotes
idInt!YesInvite 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>
}

Workspace OWNER only: lists invites of your workspace, newest first.

Auth required: Yes (workspace owner).

Arguments:

ArgumentTypeRequiredNotes
firstIntNoPage size (default 20, max 100).
afterStringNoA previous page’s pageInfo.endCursor.
statusTenantInviteStatusNoOnly 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 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:

ArgumentTypeRequiredNotes
requiredBoolean!Yestrue = require two-factor at sign-in; false = optional.
ownerCodeStringNoYour 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 (signIn answers TWO_FACTOR_ENROLMENT_REQUIRED; login is refused with FORBIDDEN, extensions.originalError.code TWO_FACTOR_ENROLMENT_REQUIRED; acceptTenantInvite creates the account but answers the same FORBIDDEN instead of signing in).
  • Sessions already open are not ended (use revokeUserSessions per 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>"
}

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:

ArgumentTypeRequiredNotes
userIdInt!YesThe user whose two-factor is removed.
ownerCodeStringNoYour 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>"
}

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:

ArgumentTypeRequiredNotes
firstIntNoPage size (default 20, max 100).
afterStringNoA previous page’s pageInfo.endCursor (with the same filters).
events[SecurityEventType!]NoOnly these event types. Omit or empty for all. ACCOUNT_UPDATED is listed only when explicitly requested.
userIdIntNoOnly events about or by this user of your workspace.
sinceDateTimeNoOnly events at or after this time (inclusive).
untilDateTimeNoOnly events at or before this time (inclusive).

Returns: WorkspaceSecurityEventConnection! (Relay forward pagination)

Each event includes:

FieldTypeNotes
idInt!Audit row id.
eventSecurityEventType!What happened.
createdAtDateTime!When recorded.
actorUserIdIntWho performed the action. Null for sign-in attempts and password reset requests.
actorNameStringActor’s display name at the time of the event.
subjectUserIdIntThe user the event is about.
subjectNameStringSubject’s current display name (while still in your workspace).
methodSignInMethodFor SIGN_IN_SUCCEEDED only.
failureReasonSignInFailureReasonFor SIGN_IN_FAILED only.
newRoleTenantRoleFor ROLE_CHANGED only.
inviteIdIntFor 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"
}

The caller’s own account security history, newest first.

Auth required: Yes.

Arguments:

ArgumentTypeRequiredNotes
firstIntNoHow 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
}

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.

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).

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.

emailVerified: Boolean! on the current user. It is a plain boolean; the time and the code are never exposed.

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, rollbackWorkflow
  • createNamedApiKey, generateApiKey
  • inviteTenantUser, resendTenantInvite
  • hireAgentTemplate, 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.

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:

ArgumentTypeRequired
emailString!Yes
idNoStringNo

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)
}
{ "email": "[email protected]" }

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:

ArgumentTypeRequired
inputRegisterInput!Yes
tokenValidateSecuredTokenInputNo — defaults to null

RegisterInput:

FieldTypeNotes
emailString!Required
passwordString!Required
fullnameString!Required
phoneCodeString!Required, e.g. "+1"
phoneNumberString!Required
fcmTokenStringOptional, for push notifications
idNoStringOptional
idTypeStringOptional
referralCodeStringOptional

ValidateSecuredTokenInput (optional, used when registration is gated behind an OTP/invite token):

FieldTypeNotes
tokenString!The secured token or secured OTP string
contactStringContact info tied to the token (email/phone); optional for SECURED_TOKEN
idFloatPreferred 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": {
"email": "[email protected]",
"password": "correct-horse-battery-staple",
"fullname": "Jamie Rivera",
"phoneCode": "+1",
"phoneNumber": "5551234567"
}
}

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.

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:

  1. If this WebbyX One identity is already linked to a Wetel account (UserIdentityEntity(provider: WEBBYX_ONE)), log into that account.
  2. 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.
  3. 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:

ArgumentTypeRequired
ticketString!Yes

Returns: AccessToken! (same shape as login)

Example:

mutation LoginWithWebbyxOne($ticket: String!) {
loginWithWebbyxOne(ticket: $ticket) {
accessToken
expiresIn
refreshToken
refreshExpiresIn
}
}
{ "ticket": "tk_a1b2c3..." }

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.

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:

ArgumentTypeRequired
providerAuthProvider!Yes
providerTokenString!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..." }
Terminal window
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..."}}'

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:

ArgumentTypeRequired
providerAuthProvider!Yes

Returns: Boolean!

Example:

mutation UnlinkIdentity($provider: AuthProvider!) {
unlinkIdentity(provider: $provider)
}
{ "provider": "WEBBYX_ONE" }

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:

ArgumentTypeRequired
billingAccountPrimaryBillingAccountEnum!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" }
Terminal window
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"}}'

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:

ArgumentTypeRequired
inputGenerateOTPInput!Yes

GenerateOTPInput:

FieldTypeNotes
emailStringProvide either email or phoneNumber
phoneNumberStringProvide either email or phoneNumber
purposeTokenPurpose!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)
}
{ "input": { "email": "[email protected]", "purpose": "RESET_PASSWORD" } }

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:

ArgumentTypeRequired
inputResetPasswordEmailInput!Yes

ResetPasswordEmailInput:

FieldTypeNotes
emailStringOptional — omitting it still returns true
purposeTokenPurpose!Typically RESET_PASSWORD

Returns: Boolean!

Example:

mutation ResetPasswordEmail($input: ResetPasswordEmailInput!) {
resetUserPasswordWithUrl(input: $input)
}
{ "input": { "email": "[email protected]", "purpose": "RESET_PASSWORD" } }

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:

ArgumentTypeRequired
inputPasswordWithOTPInput!Yes

PasswordWithOTPInput:

FieldTypeNotes
newPasswordString!Required
tokenValidateSecuredTokenInput!See shape under register above

Returns: Boolean!

Example:

mutation ForgotPasswordWithOTP($input: PasswordWithOTPInput!) {
forgotPasswordWithOTP(input: $input)
}
{
"input": {
"newPassword": "new-correct-horse-battery-staple",
"token": { "token": "123456", "contact": "[email protected]" }
}
}

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:

ArgumentTypeRequired
inputPasswordInput!Yes

PasswordInput:

FieldTypeNotes
currentPasswordString!Required. The account’s current password, checked the same way as sign-in.
newPasswordString!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 currentPassword fails with BAD_REQUEST and the message Current password is incorrect.
  • A newPassword equal to the current one fails with BAD_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, then forgotPasswordWithOTP).

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"
}
}
Terminal window
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"}}}'

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!

FieldTypeNotes
secretString!The raw TOTP secret
outputString!QR-code-ready payload (e.g. otpauth:// URI)

Example:

mutation GenerateTwoFactor {
generateTwoFactor {
secret
output
}
}

Binds 2FA to the current user by confirming a code generated from the secret returned by generateTwoFactor.

Auth required: JWT (AuthJwtGuard).

Arguments:

ArgumentTypeRequired
inputBindTwoFactorInput!Yes

BindTwoFactorInput:

FieldTypeNotes
secretString!The secret from generateTwoFactor
codeString!The current TOTP code generated from that secret

Returns: Boolean!

Example:

mutation BindTwoFactor($input: BindTwoFactorInput!) {
bindTwoFactor(input: $input)
}
{ "input": { "secret": "JBSWY3DPEHPK3PXP", "code": "482913" } }

Verifies a 2FA code. Per the schema’s own description, most integrations will not need to call this directly.

Auth required: JWT (AuthJwtGuard).

Arguments:

ArgumentTypeRequired
codeString!Yes

Returns: Boolean!

Example:

mutation VerifyTwoFactor($code: String!) {
verifyTwoFactor(code: $code)
}
{ "code": "482913" }

Removes 2FA from the current user’s account.

Auth required: JWT (AuthJwtGuard).

Arguments:

ArgumentTypeRequired
inputUnbindTwoFactorInput!Yes

UnbindTwoFactorInput:

FieldTypeNotes
codeString!Current TOTP code, required to confirm the unbind

Returns: Boolean!

Example:

mutation UnbindTwoFactor($input: UnbindTwoFactorInput!) {
unbindTwoFactor(input: $input)
}
{ "input": { "code": "482913" } }

Retrieves the profile of the currently authenticated user.

Auth required: JWT (AuthJwtGuard).

Arguments: None.

Returns: User (nullable)

FieldTypeNotes
idInt!
emailString
fullnameString!
usernameString
phoneCodeString
phoneNumberString
idNoString
idTypeString
referralCodeString!
statusUserStatusType!ACTIVE or SUSPENDED
twoFactorEnabledBoolean!Derived from whether a 2FA secret is bound — the secret itself is never exposed
tenantRoleTenantRole!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
createdAtDateTime!
updatedAtDateTime!

Example request:

query GetAuthProfile {
getAuthProfile {
id
email
fullname
status
twoFactorEnabled
}
}
Terminal window
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,
"email": "[email protected]",
"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.