threshold1

Error Reference

Two separate taxonomies, not one — because they come from two different layers of the request:

  • Business-logic errors — why a specific authentication attempt (OTP, passkey, TOTP, magic link, backup code, SMS, BYOAM) failed, after your API key was already accepted. Recorded as auth_events.failure_code and visible in your dashboard's Activity/Users/Usage pages — it's the category these docs mean whenever a Methods Reference or API Reference page links a code like invalid_code.
  • Auth-layer errors — why a request never got past the API key check itself (requireApiKey() in lib/apiKeyGuard.ts), before any auth logic runs. These are the literal code field in the JSON response.

One important gap between the two: a business-logic FailureCode is a semantic category, not always the literal code string an endpoint puts in its JSON body. Different verify endpoints collapse or rename these differently at the wire level — e.g. /api/v1/otp/verify returns "INVALID_CODE" for a wrong or expired or rate-limited attempt, while /api/v1/totp/verify returns "VERIFICATION_FAILED" for the same underlying invalid_code category. The literal wire code for each endpoint is documented on that endpoint's own API Reference page — this page documents the underlying category, its HTTP status, and what to actually do about it.

Business-logic errors

invalid_code

Status: 401 (400 specifically on passkey registration/login verify — a different failure path than the code-based methods)

The code, credential, or signature presented didn't match what was expected — a wrong OTP digit string, a failed WebAuthn assertion, a wrong backup code, a wrong TOTP code. Not expired, not already used — just wrong.

Prompt the user to re-enter it. After a few wrong attempts, offer a resend/fallback path rather than looping silently — don't auto-retry programmatically, since that only burns the attempt-rate-limit budget faster.

expired

Status: 401 (400 on an expired passkey challenge)

The code, magic link, or WebAuthn challenge outlived its TTL before it was used.

There's no way to "extend" an expired credential — request a fresh one (resend the code, or restart the passkey ceremony) and show a specific "this expired, request a new one" message rather than a generic error.

already_used

Status: 401

This exact code, link, or backup code was already consumed once — OTP/magic-link replay, a TOTP code reused within its window, or a backup code used a second time.

Treat it the same as invalid_code in the UI — the user needs a fresh credential. If a legitimate user hits this repeatedly, check for double-submission (double-clicking Verify, or the same code being submitted from two tabs/devices).

rate_limited

Status: 401 for email OTP and SMS OTP verify; 429 for backup codes. (Same underlying condition, different status per endpoint — check the error message text if you need to detect this reliably from the OTP/SMS endpoints, since their status alone doesn't distinguish it from invalid_code.)

Too many wrong attempts against the same code within its lockout window.

Stop retrying immediately and back off — direct the user to request a brand-new code rather than keep guessing against the locked one.

user_not_found

Status: 404 for TOTP verify and passkey enrollment lookups (no enrollment exists to check against); 401 specifically on passkey login verify.

No credential/enrollment exists for this account on the method being used. The passkey-login case is deliberately generic — it returns the same 401 an actual wrong-assertion would, so a caller can't use the response to enumerate which emails have a passkey registered.

For the 404 cases, direct the user to enroll first (e.g. "you haven't set up an authenticator app yet"). For the passkey-login 401 case, don't try to distinguish "no such user" from "wrong credential" in your UI — show a generic failure and offer a fallback method.

user_disabled

Status: 403

The account itself has been disabled — not a bad credential.

Block the login entirely with a distinct "account disabled" message. Don't let the user retry; retrying will fail identically no matter what they enter, since the credential was never the problem.

user_exists

Status: 409

Passkey registration only — the email being registered already has an account.

Redirect to login instead of register, or offer an "add a passkey to your existing account" flow (log in first, then enroll a new passkey).

validation_error

Status: 400

The request itself was malformed — a missing or invalid email, an empty code field, a bad body — caught before any auth logic runs.

This is a client-side bug, not an end-user-facing failure. Validate the request shape before calling the API rather than handling this in the login UI.

risk_blocked

Status: 403

Threshold1's risk engine blocked this specific attempt outright, independent of whether the credential itself was correct. The triggering riskScore/riskSignals are attached to the underlying auth_events row for your own review.

This isn't a bug in the credential flow — it's a deliberate security block. Show a neutral "we couldn't complete this sign-in" message rather than revealing risk-engine internals, and investigate via your dashboard if it's happening to attempts you believe are legitimate.

external_method_error

Status: varies by the specific BYOAM sub-reason — 400 (WRONG_PATTERN, INVALID_PAYLOAD), 401 (INVALID_SIGNATURE, STALE_TIMESTAMP), or 403 (INACTIVE_REGISTRATION).

A BYOAM (bring-your-own-auth-method) confirm callback failed at the protocol level. external_method_error is only the dashboard-facing bucket — the specific reason is the literal wire-level code/error string (e.g. "INVALID_SIGNATURE"), documented on the BYOAM API Reference page.

Read the specific wire code, not this bucket, to know what actually happened. Most of these are integration bugs on your own confirm-callback implementation — a stale or wrong signing secret, clock skew past the 60-second freshness window, or calling the wrong registered method name — rather than anything the end user did.

internal_error

Status: 500 in the large majority of cases; 502 specifically for an SMS-provider send failure; 401 in two spots (a passkey-login catch-all, and one SMS code-lookup database error) where the code deliberately avoids surfacing a raw 500 mid-login.

Something failed on Threshold1's side — a database error, an unexpected exception — not something the end user or your request caused.

Safe to retry. If it persists, it isn't a data-entry problem on the user's end — capture the request_id from that call and reach out if it recurs.

Auth-layer errors

Thrown by requireApiKey() before any of the business logic above ever runs. Every one of these codes is the literal code field you'll see in the JSON error body.

MISSING_API_KEY

Status: 401

No Authorization: Bearer <key> header was sent, or it wasn't in Bearer <token> shape.

Check that the header is actually being attached — a common cause is a fetch/SDK client constructed without an API key, or a header object built without the Bearer prefix.

INVALID_FORMAT

Status: 401

The header was present, but the token doesn't start with th_live_/th_test_, or is too short to be a real key.

Check for copy-paste truncation from the dashboard, or that you're not sending a session JWT (or some other token) in the API-key slot.

API_KEY_NOT_FOUND

Status: 401

The key's prefix doesn't match any row in the database — most likely it was deleted, or it belongs to a different project than the one you think you're calling.

Regenerate a key from the dashboard and confirm it's the right one for the environment (test vs live) you intend to call.

API_KEY_REVOKED

Status: 401

The key exists but was explicitly revoked from the dashboard.

This key will never work again — generate a replacement and update every place the old one was configured. Don't retry it.

API_KEY_EXPIRED

Status: 401

The key has an expires_at in the past — it worked once, but its lifetime is up.

Generate a replacement key from the dashboard. Not every key has an expiry set, so this only affects keys you (or a teammate) deliberately gave one.

HASH_MISMATCH

Status: 401

A request's prefix matched a real key row, but the full key's hash doesn't match what's stored — in practice, almost always a corrupted or hand-edited key.

Re-copy the full key from the dashboard rather than retyping or editing it by hand.

INVALID_API_KEY

Status: 401

The key is otherwise fully valid — found, not revoked, not expired, hash matches — but isn't attached to a project.

Create a project in the dashboard for the company that owns this key. A bare company-level key with no project can't authenticate project-scoped API calls.

RATE_LIMIT_EXCEEDED

Status: 429

This API key has exceeded 100 requests/minute, keyed on the key's own prefix.

Back off and retry after a short delay. If you're hitting this under normal usage, batch or cache calls rather than tightening your retry loop, which only makes it worse.

(GET/DELETE /api/v1/passkeys/by-external currently return 401 instead of 429 for this specific code — every other endpoint returns 429 as documented above.)

INTERNAL_ERROR

Status: 401 — deliberately, not 500. Worth knowing precisely: this is the one code in this taxonomy where a 401 does not mean your credential is bad.

Something failed on Threshold1's side while validating the key itself — a database error, a missing server config, a tenant-context setup failure.

Safe to retry. If it persists, it's ours to investigate — it isn't a problem with your key.

What's next

  • Methods Reference and API Reference pages link specific business-logic codes inline wherever a method can produce them.
  • BYOAM — the literal wire-level codes behind every external_method_error.