Skip to main content
Version: 4.1

App sign-in tickets

A signed-in HiTechCloud app can open another HiTechCloud app (User, Admin, Reseller, Mail) already signed in, without the password and without putting a token in a link. This is handoff v2 of the mobile apps' docs/INTEROP.md.

  1. The sending app asks the panel for a ticket for the app it wants to open.
  2. It opens hitechcloud-<app>://open?server=<host>&ticket=<ticket>.
  3. The receiving app posts the ticket to the panel over TLS and gets its own Bearer token, exactly as if it had called POST /api/login.

A ticket is not a credential you keep. It lives for 30 seconds by default (60 at most), works once, and is tied to the account, the target app and the server it was issued on.

The API is an Enterprise feature, so issuing needs the api feature on the account's plan. Redeeming needs nothing.

Issue a ticket​

POST /api/auth/ticket
Authorization: Bearer <token from POST /api/login>
Content-Type: application/json

{"app": "user", "ttl": 30, "bind_ip": false}
FieldRequiredMeaning
appyesuser, admin, reseller or mail. The full scheme (hitechcloud-user) is accepted too.
ttlnoSeconds the ticket lives, 1 to 60. Default 30. Anything else is 400 invalid_ttl.
bind_ipnotrue ties the ticket to the address it was issued from. The redeem must then come from the same address (an IPv6 client counts by its /64). Use it when both apps are on one device and one network.

200:

{
"ticket": "hct1_9f2c...64 hex characters...",
"app": "user",
"expires_in": 30,
"expires_at": 1760000030,
"server": "panel.example.com",
"bound_ip": false
}

The ticket is hct1_ followed by 64 hex characters (69 characters), which fits the apps' ^[A-Za-z0-9_-]{16,256}$. Treat it as opaque. Never log it, put it in analytics, or keep it after you have opened the link.

StatuserrorMeaning
400invalid_requestThe body is not JSON, or is larger than 4 KB.
400invalid_appapp is not one of the four.
400invalid_ttlttl is not 1 to 60.
400ip_unavailablebind_ip was asked for and the panel does not know the client address.
401No valid Bearer token (the panel's usual API answer).
403token_not_allowedThe token is an MCP token. Only a token from POST /api/login may issue a ticket.
403impersonation_not_allowedThe session is an administrator impersonating the account.
403suspendedThe account is suspended.
429rate_limit_exceededMore than 10 tickets in a minute for this account (ticket_issue_ratelimit).
503ticket_store_unavailableThe panel's cache is down.

Every issue is written to the account's activity log (issued a sign-in ticket for the user app (valid 30s)).

Redeem a ticket​

POST /api/auth/ticket/redeem
Content-Type: application/json

{"ticket": "hct1_...", "app": "user"}

No Authorization header, no cookie. app is the receiving app's own name and must be the one the ticket was issued for. twofa_code is optional (see below).

200 is the same sign-in as POST /api/login, plus the account name and the app:

{"token": "eyJhbGciOi...", "user_id": 1, "username": "stefan", "app": "user", "expires_in": 3600}

The token is an HS256 JWT valid for one hour. There is no refresh token: ask the sending app for a new ticket, or sign in. A redeem is a real sign-in: it is written to the login history and the activity log (logged in via sign-in ticket for the user app) and fires the account's login notification if that is on.

StatusBodyMeaning
400{"error":"invalid_request"}The body is not JSON, or is larger than 4 KB.
400{"error":"invalid_app"}app is not one of the four.
401{"error":"invalid_ticket"}Every refusal of the ticket itself: malformed, unknown, expired, already used, issued for another app, on another server or (when bound) from another address, or issued before the account turned on 2FA. The answer never says which.
401{"twofa_required":true,"user_id":1}Only with ticket_redeem_require_2fa = yes: send twofa_code. The ticket is kept.
401{"error":"invalid_2fa"}That twofa_code is wrong. The ticket is kept, the failure counts against the account.
403{"error":"suspended"}The account is suspended. The ticket is destroyed.
403{"error":"second_factor_required"}Only with ticket_redeem_require_2fa = yes, for an account that signs in with an email code. Sign in with the password in the app.
403{"error":"...","code":"...","portal_url":"..."}The account may not sign in on this front door (for example a mail-only account on the panel), as for POST /api/login.
429{"error":"rate_limit_exceeded",...}More than 10 redeems a minute from one client address (ticket_ratelimit).
503{"error":"ticket_store_unavailable"}The panel's cache is down.

An app should treat any 401 invalid_ticket as "ask the user to sign in" and must not retry the same ticket.

Rules the panel enforces​

  • Single use. The ticket is claimed with one atomic delete, so of any number of requests carrying it, exactly one signs in. A later use is refused as invalid_ticket and the account's activity log records sign-in ticket for the user app refused: already used (for ten minutes after the ticket was spent).
  • Short life. At most 60 seconds, enforced twice: by the cache's expiry and by the time inside the record.
  • Bound. To the account, the target app, the server host (the host name, without port, the ticket was issued on) and, if asked, the client address. A ticket redeemed for another app, host or address is destroyed, not merely refused, and the account's activity log says why.
  • Stored hashed. Only the SHA-256 of the ticket is kept, so a copy of the cache signs nobody in.
  • Second factor. Only a token from POST /api/login can issue a ticket, and that token needed the account's 2FA code. An account that turns 2FA on after a ticket was issued invalidates it. An operator who wants the code asked again at redeem sets ticket_redeem_require_2fa = yes.
  • Suspended accounts can neither issue nor redeem.
  • Rate limits. Issue: 10 a minute per account. Redeem: 10 a minute per client address. Both are set in the panel configuration (ticket_issue_ratelimit, ticket_ratelimit).
  • Not for agents. The two routes are not MCP tools.

What the apps do​

Sender (any app):

  1. Check the receiving app is installed, then POST /api/auth/ticket with app set to its name. Send bind_ip: true only if both apps are on this device on one network.
  2. Open hitechcloud-<app>://open?server=<panel host>&ticket=<ticket> straight away. Do not store the ticket.
  3. If the call fails (any non-200), open the link without a ticket. The receiver then shows its normal sign-in with the server and user pre-filled.

Receiver:

  1. Parse ticket with the limits in INTEROP.md section 2. Drop it unless server is a saved server (INTEROP.md already says so).
  2. POST https://<server>/api/auth/ticket/redeem with ticket and its own app name. Do not follow redirects.
  3. On 200, keep token as the app's session and forget the ticket. On any other answer, show the sign-in screen. Never log the ticket or the response.
  4. Set ticketExchangeSupported to true only for servers whose panel is 4.0.8 or newer. The panel answers 404 on an older version, which the app treats as "not supported".

Both calls go to the panel the account lives on (port 2083, or the host's default HTTPS port when the panel is served on it). The Admin app's HiTechCloud Admin (2087) API does not take tickets yet.