Harnix
Integrate

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:

HeaderBodyForm
X-API-Keyend_user_idServer-to-server: you assert the identity, trusted outright.
X-Publishable-Keyend_user_id + user_hash + expires_atSigned by you (level 2).
X-Publishable-Keyanonymous_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/chat directly with an API key, no token exchange needed.
  • Errors — invalid_publishable_key, invalid_user_hash, user_hash_expired, origin_not_allowed, rate_limited.

On this page