Token exchange
Exchange a publishable key and end-user identity for a chat session token.
POST /v2/token is the only endpoint a browser is allowed to call with
pk. The widget calls it automatically; you only need this page if you're
reimplementing the token step yourself (e.g. your own token-url), or want
to understand how user-hash works.
Request
POST https://api.harnix.vn/v2/token
Content-Type: application/json
X-Publishable-Key: pk_xxxxxxxxxxxxxxxx{
"end_user_id": "user_42",
"user_hash": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3...",
"expires_at": 1735689600
}The header decides the form, not the body:
| Header | Body | Form |
|---|---|---|
X-API-Key | end_user_id | Server-to-server: you assert the identity, trusted outright. |
X-Publishable-Key | end_user_id + user_hash + expires_at | Signed by you (level 2). |
X-Publishable-Key | anonymous_id (UUID) | Anonymous (level 1). |
If both X-API-Key and X-Publishable-Key are present, the API key wins
— pk/Origin are ignored entirely.
Response
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": 1735693200,
"end_user": { "id": "3fae...", "kind": "identified" }
}token is a JWT (HS256), short-lived (1 hour by default). kind is one of
anonymous, identified, service. Use this token as
Authorization: Bearer <token> on every subsequent request.
Signing user_hash
The formula: HMAC-SHA256(identity_secret, "{end_user_id}:{expires_at}"),
hex-encoded. Get identity_secret once from Console → Integration →
Regenerate identity secret (shown exactly once, at creation — store it
safely on your backend).
import { createHmac } from "node:crypto";
function signUserHash(identitySecret, endUserId, expiresAt) {
return createHmac("sha256", identitySecret)
.update(`${endUserId}:${expiresAt}`)
.digest("hex");
}
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // max 24h
const userHash = signUserHash(process.env.HARNIX_IDENTITY_SECRET, "user_42", expiresAt);24-hour maximum window
expires_at can't be more than 24 hours from when you sign it — a leaked
signature can never become a permanent ticket. Beyond that, or once past
the deadline, you get user_hash_expired. display_name/metadata (if
you send them) are not part of the signature — they're still your
assertion, not something the end-user proves.
end_user.metadata (optional)
You can send display_name and metadata (a flat JSON object, 4 KB max,
no nested objects/arrays) in the body — these get stored on the end-user
row, useful for display in the console. No nesting — a value that's an
object or array is rejected with validation_error.
Rate limits
Exchanging a token via pk (anonymous or signed) is limited by (pk, IP),
60 requests/minute by default — exceeding it returns rate_limited with a
retry_after. Exchanging with X-API-Key (server-to-server) has no limit
at this endpoint.
Tokens can't be revoked before they expire
An issued token can't be revoked mid-flight — but every request re-checks the end-user's and organization's state, so blocking an end-user or suspending an organization takes effect on the very next request, even while the token is still technically valid.
Next
- Server-to-server — call
/v2/chatdirectly with an API key, no token exchange needed. - Errors —
invalid_publishable_key,invalid_user_hash,user_hash_expired,origin_not_allowed,rate_limited.