> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hairhealth.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Hair Scan API

> Submit hair photos, get back a structured hair assessment — scores, flags, and metrics as data, not narrative.

<Note>
  This API returns data, not copy. No pre-written sentences, no branded language — you control how results are presented to your own users; we control the accuracy of the underlying analysis.
</Note>

AI HairScan analyses one or more hair photos and returns a structured hair assessment: an overall health score, flagged findings, region-level density, detailed metrics, and (where configured) product recommendations from your own catalog.

***

## Authentication

All requests require a bearer API key:

```bash theme={null}
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
```

Test keys (`sk_test_...`) run the full pipeline against non-billed, non-persisted requests — use them for integration testing.

<Info>
  API keys and webhook signing secrets are provisioned during onboarding. Once your account is set up, your HairHealth.ai account manager will issue your live and test keys. Sandbox credentials are available on request if you'd like to integrate against this spec before your account is fully live.
</Info>

## Base URL

<Info>
  Your base URL will be provided by HairHealth.ai in your onboarding document, along with your API key and webhook signing secret.
</Info>

Endpoint paths below (e.g. `/v1/hairscan`) are relative to that base URL. The API is versioned in the path; breaking changes will ship as `/v2`, additive changes (new optional fields) will not change the version.

***

## The scan object

This is the core object returned once a scan completes.

| Field          | Type      | Description                           |
| -------------- | --------- | ------------------------------------- |
| `scan_id`      | string    | Unique identifier for this scan       |
| `status`       | enum      | `processing`, `complete`, or `failed` |
| `created_at`   | timestamp | ISO 8601                              |
| `completed_at` | timestamp | ISO 8601                              |

<ResponseField name="subject" type="object">
  Echoes the subject info submitted with the scan

  <Expandable title="properties">
    <ResponseField name="reference_id" type="string">
      Your internal ID for this subject (optional)
    </ResponseField>

    <ResponseField name="sex" type="enum">
      `male` or `female` — determines the grading scale used in `hair_loss_stage` (Norwood for male, Ludwig-Sinclair for female)
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="hair_health_score" type="object">
  Overall score, band, confidence, and 3 pillar scores
</ResponseField>

<ResponseField name="flags" type="array">
  Notable findings worth surfacing to a user or clinician. No narrative — pair these with your own copy in your UI.
</ResponseField>

<ResponseField name="density_map" type="object">
  Four fixed scalp regions, each rated `low` / `medium` / `high` with an optional tag
</ResponseField>

<ResponseField name="metrics" type="object">
  Full detailed metrics, grouped into 2 categories
</ResponseField>

<ResponseField name="clinical_classification" type="object">
  Structured, not diagnostic language — a data label, not a report sentence
</ResponseField>

<ResponseField name="recommended_products" type="array | null">
  Top 3 ranked SKU matches — present only if a product catalog has been onboarded for your account
</ResponseField>

<ResponseField name="crm_summary" type="object">
  Compact record for CRM/lead routing
</ResponseField>

***

## Endpoints

### Create a scan

<ParamField body="subject" type="string" required>
  JSON string — see `subject` schema above
</ParamField>

<ParamField body="images_frontal" type="file" required>
  Clear frontal hairline photo
</ParamField>

<ParamField body="images_crown" type="file">
  Top-down crown photo — recommended, needed for crown flags & density
</ParamField>

<ParamField body="images_temples" type="file">
  Improves hairline recession accuracy
</ParamField>

<ParamField body="images_donor" type="file">
  Back/sides — needed for donor region reading
</ParamField>

<ParamField body="intake_answers" type="string">
  Free-form JSON object, e.g. `family_history`, `onset_duration_months`. Exact fields are configured per account — talk to your account manager to set up your intake schema.
</ParamField>

<ParamField body="webhook_url" type="string">
  Overrides your account's default webhook URL for this scan
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://<YOUR_BASE_URL>/v1/hairscan \
    -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx" \
    -F "subject={\"age\":34,\"sex\":\"male\"}" \
    -F "images_frontal=@frontal.jpg" \
    -F "images_crown=@crown.jpg"
  ```

  ```python Python theme={null}
  import requests
   
  response = requests.post(
      "https://<YOUR_BASE_URL>/v1/hairscan",
      headers={"Authorization": "Bearer sk_live_xxxxxxxxxxxxxxxx"},
      data={"subject": '{"age": 34, "sex": "male"}'},
      files={
          "images_frontal": open("frontal.jpg", "rb"),
          "images_crown": open("crown.jpg", "rb"),
      },
  )
  ```

  ```javascript Node.js theme={null}
  const form = new FormData();
  form.append("subject", JSON.stringify({ age: 34, sex: "male" }));
  form.append("images_frontal", frontalFile);
  form.append("images_crown", crownFile);
   
  const response = await fetch("https://<YOUR_BASE_URL>/v1/hairscan", {
    method: "POST",
    headers: { Authorization: "Bearer sk_live_xxxxxxxxxxxxxxxx" },
    body: form,
  });
  ```
</CodeGroup>

Response — `202 Accepted`, returned immediately:

```json theme={null}
{
  "scan_id": "scan_8f2a1c9e",
  "status": "processing",
  "created_at": "2026-07-05T14:32:00Z"
}
```

### Retrieve a scan

```text theme={null}
GET /v1/hairscan/{scan_id}
```

<CodeGroup>
  ```bash cURL theme={null}
  curl https://<YOUR_BASE_URL>/v1/hairscan/scan_8f2a1c9e \
    -H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx"
  ```
</CodeGroup>

While processing:

```json theme={null}
{ "scan_id": "scan_8f2a1c9e", "status": "processing" }
```

Once complete, returns the full [scan object](#the-scan-object).

If it fails:

```json theme={null}
{
  "scan_id": "scan_8f2a1c9e",
  "status": "failed",
  "error": { "code": "low_image_quality", "message": "Frontal image too blurry to analyse." }
}
```

***

## Webhooks

Instead of polling, register a `webhook_url` on your account (or per-request) to be notified when a scan completes.

```text theme={null}
POST <your webhook_url>
Content-Type: application/json
X-HairHealth-Signature: sha256=<hmac>
```

```json theme={null}
{
  "event": "hairscan.completed",
  "scan_id": "scan_8f2a1c9e",
  "data": { /* full scan object */ }
}
```

`event` is `hairscan.completed` or `hairscan.failed`.

<Warning>
  Always verify the `X-HairHealth-Signature` header before trusting a webhook payload. Compute an HMAC-SHA256 of the raw request body using your webhook signing secret (issued alongside your API key) and compare it to the header. Reject the request if they don't match.
</Warning>

***

## Errors

Errors return a standard shape:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "missing_image",
    "message": "images_frontal is required."
  }
}
```

| HTTP status | `type`                                  |
| ----------- | --------------------------------------- |
| 400         | `invalid_request_error`                 |
| 401         | `authentication_error`                  |
| 404         | `not_found_error`                       |
| 422         | `processing_error` (e.g. image quality) |
| 429         | `rate_limit_error`                      |
| 500         | `api_error`                             |

<Note>
  `message` is for developers/logs — don't surface it directly to end users.
</Note>

***

## Rate limits

Each API key has a request quota tied to your account's plan. Every response includes:

```text theme={null}
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1720188000
```

Exceeding your quota returns `429` with `type: rate_limit_error`.

***

## Changelog

* **v1** — initial release.
