Skip to main content
The Trellis API uses a two-step authentication flow:
  1. Exchange your API key for a JWT token.
  2. Use the JWT token to authenticate every subsequent request.
The JWT is valid for six hours; refresh by re-exchanging your API key when it expires.

Obtaining an API key

API keys are provisioned through the Trellis admin dashboard. Contact your organization administrator or reach out to Trellis support to obtain one.
Keep your API key secure. Do not expose it in client-side code or commit it to version control.

Computing the user_key

Every call to /v1/authenticate must include a user_key alongside the email. The user_key proves the request was authorized by your backend rather than forged client-side — any party with your API key can claim an email, so we require an HMAC-derived value that only your backend can produce.
user_key = base64url_nopad( HMAC-SHA256( shared_secret, normalize(email) ) )

normalize(email) = email.strip().lower()
The output is exactly 43 ASCII characters from the set [A-Za-z0-9_-]. Base64url-encoded without padding (strip trailing =). HMAC is over the raw bytes of the normalized email (lowercased, whitespace-trimmed), UTF-8 encoded.
The user_key is a signature, not an identifier. Trellis recomputes HMAC(secret, email) server-side using the email from the request and compares it to the submitted user_key. A user_key is bound to one specific email — submitting one user’s email with another user’s user_key always returns 401 Authentication failed, regardless of whether both users are provisioned. The only way to authenticate as a given user is to present their specific (email, user_key) pair.
The shared HMAC secret must stay on your backend. Never embed it in mobile apps, single-page apps, or any client-side code. Authenticate the user with your own login flow first, then compute user_key server-side and hand the (email, user_key) pair to the client to forward to Trellis.
import base64, hashlib, hmac

def compute_user_key(email: str, shared_secret: str) -> str:
    normalized = email.strip().lower()
    secret_bytes = base64.urlsafe_b64decode(
        shared_secret + "=" * (-len(shared_secret) % 4)
    )
    tag = hmac.new(secret_bytes, normalized.encode("utf-8"), hashlib.sha256).digest()
    return base64.urlsafe_b64encode(tag).rstrip(b"=").decode("ascii")
const crypto = require("crypto");

function computeUserKey(email, sharedSecret) {
  const normalized = email.trim().toLowerCase();
  const secretBytes = Buffer.from(sharedSecret, "base64url");
  return crypto
    .createHmac("sha256", secretBytes)
    .update(normalized, "utf8")
    .digest("base64url"); // already unpadded
}
To obtain the shared HMAC secret for your organization, contact Trellis support. Rotating the secret invalidates every outstanding user_key, so we coordinate timing with you.
Use the same normalization on both sides. The HMAC is computed over email.strip().lower(). If your normalization differs from Trellis’s, the HMAC won’t verify and the request will return 401. Send the email in the JSON body in its normalized form too — Trellis re-normalizes on its side, but matching ahead of time avoids edge cases with mixed-case domains.

Authentication flow

Step 1: Exchange API key for JWT

To start a session, send your API key, the user’s email address, and the computed user_key to the authenticate endpoint.
curl -X POST https://api.trellis.sh/v1/authenticate \
  -H "X-API-Key: your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "user_key": "your_computed_user_key"}'
Response:
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "member_id": "550e8400-e29b-41d4-a716-446655440000",
  "organization_id": "org_abc123",
  "expires_at": "2025-01-11T18:00:00Z"
}
Users must be pre-provisioned by Trellis before they can authenticate. Auto-creation on first login was removed. To add a new user, contact your Trellis administrator with the email address you want enabled — authenticating with an unknown or disabled email returns 401 Authentication failed.

Step 2: Use the JWT token

Include the JWT token in the Authorization header for all API requests:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Token expiration

JWT tokens expire 6 hours after they are issued. The expires_at field in the authentication response tells you exactly when. When a token expires, you’ll receive a 401 Unauthorized response. Simply re-authenticate to get a new token.
For long-running applications, implement token refresh logic that re-authenticates before the token expires.

Token identity (jti) and rate-limit buckets

Every JWT issued by /v1/authenticate carries a unique jti (JSON Web Token ID) claim — a v4 UUID generated at sign time. Trellis uses jti as the bucket key for rate limits and the WebSocket concurrent-connection cap. Practical implications:
  • Re-authenticating returns a fresh jti, so a new token resets every per-token throttle and concurrency slot.
  • Two services sharing one JWT also share its rate-limit and connection budgets. Issue separate tokens to separate them.
  • The jti is opaque — clients don’t need to inspect or echo it; just keep using the token string.

Error responses

All authentication failures return a single generic 401 Authentication failed so the specific failure mode isn’t disclosed to attackers. Treat them all as “credentials rejected — prompt the user to retry login.”
StatusBodyWhen
400{"detail": "Invalid email format"}The email address is structurally invalid (missing @, over 320 chars).
401{"detail": "Authentication failed"}Missing or invalid API key, unknown email, disabled user, wrong user_key, or cross-tenant misuse.
422Pydantic validation error with field detailuser_key is missing or doesn’t match ^[A-Za-z0-9_-]{43}$.
429{"code": "RATE_LIMIT", "retry_after_seconds": N} plus a Retry-After: N headerPer-API-key rate limit on /v1/authenticate exceeded (60 requests per minute). Respect Retry-After and back off.
500{"detail": "JWT signing not configured"} or {"detail": "Server misconfiguration"}Trellis-side configuration error. Contact support.
A 401 against a known-good email usually means a normalization mismatch when computing user_key. Verify you’re lowercasing and trimming the email before HMAC, and that you’re feeding raw bytes (not the base64 string itself) of the decoded shared secret to the HMAC function.