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.
- The sending app asks the panel for a ticket for the app it wants to open.
- It opens
hitechcloud-<app>://open?server=<host>&ticket=<ticket>. - 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}
| Field | Required | Meaning |
|---|---|---|
app | yes | user, admin, reseller or mail. The full scheme (hitechcloud-user) is accepted too. |
ttl | no | Seconds the ticket lives, 1 to 60. Default 30. Anything else is 400 invalid_ttl. |
bind_ip | no | true 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.
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | The body is not JSON, or is larger than 4 KB. |
| 400 | invalid_app | app is not one of the four. |
| 400 | invalid_ttl | ttl is not 1 to 60. |
| 400 | ip_unavailable | bind_ip was asked for and the panel does not know the client address. |
| 401 | No valid Bearer token (the panel's usual API answer). | |
| 403 | token_not_allowed | The token is an MCP token. Only a token from POST /api/login may issue a ticket. |
| 403 | impersonation_not_allowed | The session is an administrator impersonating the account. |
| 403 | suspended | The account is suspended. |
| 429 | rate_limit_exceeded | More than 10 tickets in a minute for this account (ticket_issue_ratelimit). |
| 503 | ticket_store_unavailable | The 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.
| Status | Body | Meaning |
|---|---|---|
| 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_ticketand the account's activity log recordssign-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/logincan 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 setsticket_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):
- Check the receiving app is installed, then
POST /api/auth/ticketwithappset to its name. Sendbind_ip: trueonly if both apps are on this device on one network. - Open
hitechcloud-<app>://open?server=<panel host>&ticket=<ticket>straight away. Do not store the ticket. - 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:
- Parse
ticketwith the limits in INTEROP.md section 2. Drop it unlessserveris a saved server (INTEROP.md already says so). POST https://<server>/api/auth/ticket/redeemwithticketand its ownappname. Do not follow redirects.- On
200, keeptokenas the app's session and forget the ticket. On any other answer, show the sign-in screen. Never log the ticket or the response. - Set
ticketExchangeSupportedto true only for servers whose panel is 4.0.8 or newer. The panel answers404on 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.