Skip to content

API reference / Errors

Every error code, in one place

Every HyperCRM API response, success or failure, follows one of two envelope shapes, with the few exceptions listed below. This page lists both shapes, how request ids work, and the full table of error codes and HTTP statuses an endpoint can return.

The two envelopes

Every HyperCRM API response follows one of two envelope shapes, with these exceptions: the OAuth endpoints /api/v1/oauth/token, /api/v1/oauth/register and /api/v1/oauth/revoke use the standard OAuth body (error, error_description); a CSV export returns a file; GET /api/v1/files/{fileId}/content is a 302 to the stored file; /api/v1/openapi.json is the specification document itself; and the MCP endpoint speaks JSON-RPC rather than REST.

Successful responses (2xx) always look like this, with data holding the resource and requestId for correlation:

{
  "data": { "...": "resource-specific shape" },
  "requestId": "req_5f2c1e40-..."
}

POST endpoints that create a resource return 201 with the same shape. Error responses (4xx/5xx) share the mirror shape, with a stable code, a safe-to-display message, the same requestId, and an optional details array. Each entry has a message and may have a path, a field and a machine-readable code (for example invalid_json, or chair_booked on a booking conflict):

{
  "error": {
    "code": "validation_failed",
    "message": "Request validation failed.",
    "requestId": "req_5f2c1e40-...",
    "details": [ { "path": ["firstName"], "message": "firstName is required" } ]
  }
}

The detail object

Separately from the details array, a refusal that a client can act on carries a detail object holding the machine-readable reason, for example detail.feature on a plan refusal or detail.limit and detail.used on a cap. The keys in use are feature, resource, limit, used, requiredPlan, cost, remaining, resetsAt, reason, since, practiceName and practiceLogoUrl. Read the keys you know by name rather than switching on the whole set: more are added as new refusals need them, and an unknown one should be ignored rather than treated as an error.

Rate-limit headers

A 429 carries Retry-After in seconds, never below 1, together with RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window resets). A successful response carries none of these, so read the budget from the 429 you already have to handle. Per minute, REST allows 300 requests per user, 120 of them writes, and 600 per IP address; the MCP endpoint allows 120 per access token.

The OAuth endpoints are different

/api/v1/oauth/token, /api/v1/oauth/register and /api/v1/oauth/revoke answer in the OAuth grammar instead of the envelope above: error and error_description, with the status in the response line. The codes are the registered OAuth ones, so a standard client library already understands them: invalid_request, invalid_client, invalid_grant, unsupported_grant_type and invalid_target (400), server_error for anything 5xx, and temporarily_unavailable for a throttled request, which keeps the 429 status so a client that only knows the OAuth list still backs off correctly.

Request ids

Every JSON response carries an x-request-id header, and the same value appears as requestId in the body. A CSV download and a redirect to file content carry neither. If the incoming request supplies its own x-request-id (or x-correlation-id) header matching a safe pattern, that value is echoed back for client-side log correlation; otherwise the server generates one of the form req_<uuid>.

Back to the API reference hub for the other resource pages.

Every error code

Statuses are fixed per code on the endpoints documented here. Some endpoints override the message with something more specific while keeping the same code and status, so match on the code, never on the message text.

CodeStatusWhen it happens
validation_failed400A field failed validation, the body carried an unrecognized field, or the JSON was malformed. Carries details.
bad_request400A malformed request that is not a field-level validation failure.
unauthorized401Returned only by internal webhook and task-runner endpoints. An API caller gets the bearer-token codes below instead.
forbidden403Authenticated, but not allowed to do this: for example exporting data as a non-owner.
pack_feature_unavailable403The feature belongs to another type of practice: for example the dental chart on a practice that is not dental. No plan unlocks it. detail.feature names it.
subscription_read_only403The practice has no active plan, so writes are refused; reads and exports still work. `detail.reason` says why (trial ended, payment failed, cancelled).
plan_feature_required403The action needs a feature the practice's plan does not include. `detail.feature` names it and `detail.requiredPlan` names the plan that has it.
plan_limit_reached403A plan cap was reached. `detail.limit` and `detail.used` give the cap and the current count, and `detail.resource` names what was capped.
credits_exhausted403The action costs credits and the wallet cannot cover it. `detail.cost` and `detail.remaining` give the price and the balance.
feature_unavailable403The feature is switched off for this practice by a feature flag, rather than by its plan.
email_not_verified403The action needs a confirmed email address on the calling account, for example a CSV export.
mfa_required403The practice requires passkey verification, which a bearer token alone cannot satisfy.
reauth_required403The action needs a recent sign-in (within 10 minutes), for example approving an OAuth client.
practice_email_unverified403Emailing a patient before an owner of the practice has confirmed their own email address.
not_found404No such resource in your practice. Also returned for a malformed path id, so the two are indistinguishable.
conflict409The request conflicts with current state: a duplicate tag title, for instance.
already_submitted409A public form link that has already been submitted.
entries_not_billable409Issuing a receipt or invoice for chosen charges when one of them is not an unbilled charge of that patient.
appointment_already_charged409Charging an appointment that already has a charge that is not voided. Void that charge first.
appointment_not_chargeable409Charging an appointment that is not a confirmed patient appointment: a request, a declined, cancelled or expired booking, a reminder or a blocked slot.
appointment_not_charged409A payment that names an appointment with no charge that is not voided. Charge the appointment first, then record the payment.
charge_has_instalments409Voiding a charge that still has instalments that are not voided. Void the instalments first.
instalment_parent_locked409Voiding an instalment whose charge is voided or on a document, so the amount cannot go back to it. Void that document first.
invoice_voided409Emailing a receipt or invoice that has been voided.
token_expired410A public link has expired.
auth_email_conflict409Another sign-in identity already owns this email address.
import_limit_reached409Too many patient imports are already waiting to be applied.
invitation_already_yours409The invitation link has already been used by this account.
receipt_not_paid409A receipt was requested for an entry that has not been paid.
payload_too_large413The request body exceeds the maximum accepted size.
recipient_tax_id_required422A tax invoice was requested for a patient with no tax id on file. Add it, or issue a receipt.
patient_email_missing422Emailing a receipt or invoice when there is no address to send to: the patient has no email on file, the record was deleted, or the document is not linked to a patient. detail.reason says which.
rate_limit_exceeded429A rate limit was reached. Carries the rate-limit headers described above, and `Retry-After` for how long to wait.
patient_phone_missing422Sending by SMS was asked for and the patient has no mobile number.
consent_expired400The consent proof backing an OAuth approval is missing, expired, already used, or was minted for another app, person or practice; sign in again and retry.
session_ended401A staff sign-in session has ended.
internal_error500An unexpected server error. Nothing about the cause is returned; quote the requestId when reporting it.
service_unavailable503A dependency is temporarily unavailable.
email_not_configured503Email sending is not set up on this server.
missing_authorization401No Authorization header was sent.
sms_not_configured503The server has no SMS provider configured.
refund_unavailable503The payment provider could not be reached to issue a refund.
invalid_authorization_header401The header was present but not in the form Bearer <token>.
invalid_auth_token401The token is not valid.
expired_auth_token401The token has expired. Refresh it and retry.
revoked_auth_token401The token has been revoked.
auth_token_verification_failed401The token could not be verified for a reason with no more specific code.
disabled_auth_token403The token verifies, but the identity behind it is disabled, hence 403 rather than 401.
unsupported_auth_provider403That sign-in provider is not accepted for API access.
auth_provider_email_required403Reserved. Defined for a sign-in provider that supplies no email address; nothing throws it today.
user_disabled403Reserved. A HyperCRM account that is disabled is refused as `forbidden` instead, with "This HyperCRM account is disabled." `user_disabled` is an audit-log action name, not a response code.
auth_configuration_missing503Authentication is not configured on the server.

Common questions

Patient records are encrypted, both where they are stored and on their way to your screen. Files are private, and a link to one stops working after a short time. Changes to patient records are recorded, with who made them and when. There is no shared practice password: everyone has their own account. On the Complete plan, the practice owner can also require two-step sign-in for the whole team.

No. It's included only when there are structured, field-level issues to report, such as field validation failures, each with a message and, where relevant, a path, field or code. When there's nothing field-specific to say, the key is omitted entirely from the response body rather than sent as an empty array.

Use the requestId value from the response body, or the identical x-request-id response header. If your own request already sent an x-request-id or x-correlation-id header in the expected format, that same value is echoed back, so you can generate your own id up front and match on it end to end.

Because the bearer token itself is still valid and verifies correctly. The identity behind it is disabled, which HyperCRM treats as an authorization failure rather than an authentication failure. It's a deliberate exception from the more common convention, documented explicitly on the authentication reference page.