Migration Guide
Numeric → semantic error codes
v5.3.0-alpha.4 — every error code switched from opaque numerics (HM-AUTH-001) to SCREAMING_SNAKE_CASE semantics (HM-AUTH-MISSING). Status codes preserved; multi-meaning numerics split.
DocsMigrationsError codes (v5.3.0-alpha.4)
Breaking change for code that pins on err.code string values.
Response bodies now emit semantic codes only — they are not bilingual. If your code does if (err.code === 'HM-AUTH-001') it will silently miss every error after this version. Search and replace using the full mapping table below, or use the LEGACY_CODE_ALIASES map strategy described later on this page.
What changed
Before this ship, HM-AUTH-001 meant “Unauthorized” on one route, “Sign-in required” on another, “User not found” on a third, and “Invalid email or password” in the registry. Same code, four different conditions. An SDK consumer trying to handle the error class couldn't pin reliably — they had to parse the message string, which is brittle (the messages are user-facing English and may be localized in the future).
Each numeric was split into 1–3 specific semantic codes that name the actual condition. The code is the documentation now: HM-AUTH-MISSING tells you exactly what to fix without parsing English.
Format
HM-{DOMAIN}-{CONDITION}
Examples:
HM-AUTH-MISSING
HM-AUTH-INVALID_KEY
HM-VALIDATION-FAILED
HM-RATELIMIT-EXCEEDED
HM-THREAD-NOT_FOUNDDomain is the resource or concern (AUTH, TENANT, THREAD, AI, WEBHOOK, etc.). Condition is SCREAMING_SNAKE_CASE describing what failed. The pair is unique. No version is encoded — codes are stable contracts.
Migration steps
- 1
Search your codebase for legacy numeric codes.
Grep for
HM-[A-Z]+-\d+across your project. Common matches: error-handling middleware, alert rules, dashboard filters, retry-classification helpers. - 2
For each match, find its semantic equivalent.
Use the mapping table below. Where one numeric maps to several semantics, the call-site context tells you which one — e.g. on a sign-in route,
HM-AUTH-001almost always meantHM-AUTH-INVALID_CREDENTIALS; on a protected API route it meantHM-AUTH-MISSING. - 3
If you cannot enumerate all sites cleanly, use a translation map.
Wrap your error-handling at one place with a function that translates legacy codes to semantics for backwards-compat downstream consumers (alerts, dashboards). Example below.
- 4
Verify with a smoke test.
Trigger a known auth failure (e.g. omit the Authorization header) and confirm the returned
err.codeisHM-AUTH-MISSING, notHM-AUTH-001.
Before / after — Node.js
Before (broken after upgrade)
try {
await client.threads.list();
} catch (err) {
if (err.code === 'HM-AUTH-001') {
// Was: catch all auth failures.
// After upgrade: NEVER fires — code is now HM-AUTH-MISSING / HM-AUTH-INVALID_KEY.
refreshApiKey();
}
}After (handles every auth case explicitly)
try {
await client.threads.list();
} catch (err) {
// Domain prefix matches every auth-related code.
if (err.code?.startsWith('HM-AUTH-')) {
refreshApiKey();
}
// Or, narrower handlers when the recovery differs:
if (err.code === 'HM-AUTH-INVALID_KEY') {
promptUserToGenerateNewKey();
}
if (err.code === 'HM-RATELIMIT-EXCEEDED') {
backoffAndRetry(err.headers['retry-after']);
}
}Before / after — Python
Before
try:
threads = client.threads.list()
except HelpMeshError as err:
if err.code == "HM-AUTH-001":
refresh_api_key()After
try:
threads = client.threads.list()
except HelpMeshError as err:
if err.code.startswith("HM-AUTH-"):
refresh_api_key()
elif err.code == "HM-RATELIMIT-EXCEEDED":
retry_after = err.headers.get("retry-after", 1)
time.sleep(int(retry_after))Translation map strategy (LEGACY_CODE_ALIASES)
The HelpMesh source ships a LEGACY_CODE_ALIASES reverse-lookup map you can mirror in your own code. It maps every retired numeric code to its modern semantic(s). If your error-handling has many call sites and refactoring them all in one PR is risky, wrap responses at the boundary instead and translate downstream:
// Wrap your existing alerting/dashboard code so old rules keep firing
// during the transition window. Remove this shim once all consumers
// are updated to recognize semantic codes directly.
const SEMANTIC_TO_LEGACY: Record<string, string> = {
'HM-AUTH-MISSING': 'HM-AUTH-001',
'HM-AUTH-INVALID_KEY': 'HM-AUTH-001',
'HM-AUTH-INVALID_CREDENTIALS': 'HM-AUTH-001',
'HM-AUTH-USER_NOT_FOUND': 'HM-AUTH-001',
'HM-RATELIMIT-EXCEEDED': 'HM-RATE-001',
// ...full map mirrors LEGACY_CODE_ALIASES from packages/core/src/errors/registry.ts
};
function classifyError(err: { code: string }) {
// New code paths see semantics:
newAlertingPath(err.code);
// Legacy dashboards keep working:
const legacy = SEMANTIC_TO_LEGACY[err.code];
if (legacy) legacyAlertingPath(legacy);
}Mapping table — retired numeric codes
The 10 highest-frequency numerics. The full 44-entry map ships with the source at packages/core/src/errors/registry.ts.
| Old code | New code(s) | What changed |
|---|---|---|
HM-AUTH-001 | HM-AUTH-MISSINGHM-AUTH-INVALID_CREDENTIALSHM-AUTH-USER_NOT_FOUND | Was overloaded across "missing key", "wrong password", and "user deleted". Now split. Note: Status changed for one case: USER_NOT_FOUND now returns 404 (was 401). |
HM-AUTH-002 | HM-AUTH-TOKEN_EXPIREDHM-AUTH-PASSWORD_INCORRECTHM-AUTH-PASSWORD_NOT_SET | Different routes meant different things. Token-flow vs password-flow vs OAuth-only-account. |
HM-AUTH-003 | HM-AUTH-FORBIDDENHM-AUTH-RESET_LINK_INVALID | Permissions denial vs broken reset link — same code, very different fixes. |
HM-VALIDATION-001 | HM-VALIDATION-FAILEDHM-VALIDATION-MISSING_FIELDHM-VALIDATION-INVALID_FIELD | Generic Zod validation, plus two narrower codes for "field not provided" vs "field bad value". |
HM-AI-001 | HM-AI-DISABLEDHM-AI-PROVIDER_ERROR | "AI off for this tenant" vs "Anthropic returned 503" — wildly different remediation. |
HM-AI-002 | HM-AI-RATE_LIMITEDHM-AI-SEARCH_FAILED | AI rate-limit hits vs KB search internal error. |
HM-CONFLICT-001 | HM-CONFLICT-DUPLICATE | Resource already exists. Renamed for clarity. |
HM-WIDGET-003 | HM-WIDGET-SESSION_REQUIREDHM-WIDGET-IDENTIFIED_REQUIRED | Widget anonymous-session vs identified-customer-session distinction. |
HM-RATE-001 + HM-RATELIMIT-001 | HM-RATELIMIT-EXCEEDED | The codebase had two codes for the same condition. Collapsed. |
HM-TENANT-001 | HM-TENANT-NOT_FOUNDHM-TENANT-SUSPENDED | Subdomain → tenant lookup miss vs explicitly suspended account. |
HTTP status codes preserved.
Every status code matches the prior behavior except two specific corrections: the two “User not found” sites previously returned 401 with HM-AUTH-001. Those should be 404 with HM-AUTH-USER_NOT_FOUND (the user could be deleted, not just unauthenticated). If your retry logic was based on the 401, it will need to handle the 404 case explicitly.
All semantic codes in one place
The complete public-facing error registry (39 codes shown — the registry has 47 entries; the remainder are internal-only).
| Code | Status | Meaning |
|---|---|---|
HM-AUTH-MISSING | 401 | No API key or session. |
HM-AUTH-INVALID_KEY | 401 | API key is invalid, revoked, or expired. |
HM-AUTH-INVALID_CREDENTIALS | 401 | Wrong email or password. |
HM-AUTH-USER_NOT_FOUND | 404 | User account does not exist. |
HM-AUTH-TOKEN_EXPIRED | 401 | Auth token has expired. |
HM-AUTH-PASSWORD_INCORRECT | 401 | Current password is wrong (during change-password). |
HM-AUTH-PASSWORD_NOT_SET | 400 | OAuth-only account — set a password first. |
HM-AUTH-FORBIDDEN | 403 | Authenticated but not permitted. |
HM-AUTH-ROLE | 403 | Role does not permit this action. |
HM-AUTH-SCOPE | 403 | API key missing required scope. |
HM-AUTH-RESET_LINK_INVALID | 400 | Password reset link expired or already used. |
HM-AUTH-ACCOUNT_LOCKED | 423 | Account locked after too many failed attempts. |
HM-AUTH-SESSION_INVALID | 401 | Session revoked or invalid. |
HM-TENANT-NOT_FOUND | 404 | Subdomain does not match a tenant. |
HM-TENANT-SUSPENDED | 403 | Tenant account suspended. |
HM-TENANT-LIMIT_REACHED | 429 | Plan limit reached. |
HM-THREAD-NOT_FOUND | 404 | Thread id or ticket number does not match. |
HM-THREAD-ALREADY_CLOSED | 409 | Action requires an open thread. |
HM-THREAD-ASSIGNMENT_FAILED | 422 | Cannot assign — agent inactive or off team. |
HM-MESSAGE-NOT_FOUND | 404 | Message id does not match. |
HM-MESSAGE-TOO_LARGE | 413 | Message body or attachments over limit. |
HM-CUSTOMER-NOT_FOUND | 404 | Customer id does not match. |
HM-CUSTOMER-DUPLICATE | 409 | Email or external id already in use. |
HM-VALIDATION-FAILED | 400 | Generic Zod validation error. `error.details[]` carries field-level info. |
HM-VALIDATION-MISSING_FIELD | 400 | Required field omitted. |
HM-VALIDATION-INVALID_FIELD | 400 | Field present but value is invalid. |
HM-RATELIMIT-EXCEEDED | 429 | Per-IP, per-tenant, or auth-bucket rate limit hit. Honor `Retry-After`. |
HM-AI-DISABLED | 403 | AI features off for this tenant. |
HM-AI-PROVIDER_ERROR | 502 | Upstream AI provider returned an error. |
HM-AI-RATE_LIMITED | 429 | AI provider rate-limited the call. |
HM-AI-SEARCH_FAILED | 500 | KB semantic search failed. |
HM-AI-CONTENT_FILTERED | 422 | Output blocked by AI safety policy. |
HM-WEBHOOK-DELIVERY_FAILED | 502 | Webhook receiver returned non-2xx after retries. |
HM-WEBHOOK-INVALID_SIGNATURE | 401 | HMAC verification failed on inbound webhook. |
HM-CONFLICT-DUPLICATE | 409 | Resource already exists (slug, shortcut, etc). |
HM-KB-ARTICLE_NOT_FOUND | 404 | KB article slug does not match. |
HM-INBOX-AMBIGUOUS_ROUTING | 422 | Cannot route inbound to a single inbox. |
HM-INVITE-INVALID | 400 | Invite token expired or already used. |
HM-SLA-BREACH | 422 | SLA policy was breached. |
Using the official SDK?
@helpmesh-io/sdk ≥ 5.1.0 already emits semantic codes. Run npm i @helpmesh-io/sdk@latest to pick up the latest typed error class. Code that does err instanceof HelpMeshAuthError instead of pinning on string codes is unaffected by this migration.