---
title: "Scan Gemstone Endpoint"
method: POST
path: "/api/v1/gems/scan"
tags: ["Gemstones"]
---

# Scan Gemstone Endpoint

`POST /api/v1/gems/scan`

## Request

- Base URL: `https://api.gemstoneaiapp.com` (the document declares no server; this is the origin it was published from)
- URL: `https://api.gemstoneaiapp.com/api/v1/gems/scan`
- Auth: none declared

POST /api/v1/gems/scan — single-call GPT-5.5 Pro scan (LNK-G55R + LNK-SIA).

Two modes (controlled by scan_intent + item_id):

  new_item (default): creates a new Gemstone row with the uploaded images.
                      Existing behavior — backwards compatible.

  add_angle:          attaches new images to an existing Gemstone (item_id),
                      re-runs scan_v2 with the FULL image set (≤5 cap),
                      updates the canonical Gemstone fields in place, and
                      creates a Scan history row. First add_angle per Item
                      is free (no quota consumed). Subsequent add_angles
                      on the same Item count normally.

Pipeline (new_item):
  1. Validate auth + images; a fingerprint cache hit on the caller's OWN
     earlier scan returns the stored result here, uncharged and exempt
     from the limit (LNK-SCANIDEM)
  2. Scan limits + quota reservation (cache miss only); persist images
  3. scan_v2(images, edge_hint) — Gemini pre-pass + GPT-5.5 Pro w/ web_search
  4. ScanV2Result Pydantic validation (surfaces malformed AI output as 5xx)
  5. Persist via ScanService.process_scan_v2_result (scan_version = 2)
  6. Increment scan / device counter
  7. Return ScanV2SuccessResponse{gemstone_id, images, scan}

Pipeline (add_angle):
  1. Validate auth + scan limits (free if first add_angle for this Item)
  2. Load existing Gemstone, validate ownership + combined image count ≤ 5
  3. Validate + persist new images
  4. Fetch existing images, combine with new, run scan_v2 with full set
  5. ScanV2Result validation
  6. Persist via ScanService.process_add_angle_result (update in place,
     add new GemstoneImage rows linked to the new Scan, increment
     add_angle_count)
  7. Increment scan counter only if NOT free
  8. Return ScanV2SuccessResponse{gemstone_id=item_id, images, scan}

Form fields (LNK-SIA additions):
  item_id     (Optional[int])  — existing gemstone.id; required if scan_intent=add_angle
  scan_intent (Optional[str])  — 'new_item' (default) or 'add_angle'

Headers:
  Authorization (optional)  — Bearer JWT for authenticated users
  X-Device-Id               — device instance id; REQUIRED for anonymous
                              scans (register via POST /api/v1/device first)
  X-Scan-Source             — "mobile" (default) or "web" (requires auth)

Limits (LNK-LADDER):
  Authenticated FREE    — base 1 scan/month + earned bonus scans
                          (signup +1, first save-to-collection +1);
                          1 free add_angle per Item
  Authenticated PREMIUM — unlimited
  Anonymous             — 1 free scan (ANONYMOUS_SCAN_LIMIT), tracked per
                          device. Requests without X-Device-Id are rejected
                          with 400 — there is no untracked anonymous mode.

Returns:
  200 ScanV2SuccessResponse — successful scan, full v2 result included
  200 ScanV2ErrorResponse   — image rejected (not a gemstone) or AI failure
  400                       — invalid scan_intent / image cap exceeded /
                              anonymous scan without X-Device-Id header
  401                       — web scan without authentication
  402                       — scan limit exceeded
  403                       — add_angle on Item owned by someone else
  404                       — add_angle item_id not found

## Headers

- `X-Device-Id` integer, nullable
- `X-Scan-Source` string, nullable
- `X-App-Version` string, nullable
- `authorization` string, nullable
- `X-API-Version` string, nullable

## Response `200`

Successful Response

- union
  - ScanV2SuccessResponse: Wire response for a successful POST /api/v1/gems/scan in the v2 pipeline. Combines the persisted Gemstone row id + image list with the full ScanV2Result the mobile result screen renders. Mobile reads `gemstone_id` to wire collection-save and chat-refinement flows, and `scan` for the UI.
    - `success` true
    - `gemstone_id` integer, required
    - `images` ScanV2GemstoneImage[]
      - `id` integer, required
      - `image_url` string, required
      - `thumb_url` string, nullable
    - `scan` ScanV2Result, required
      - `pricing` PricingResult
        - `stage` 'initial' | 'researched'
        - `display_assumption` string, nullable
        - `version` string, required
        - `status` 'pending' | 'ready' | 'partial' | 'failed' | 'deferred', required
        - `subject_key` string, required
        - `currency` string
        - `priced_at` string, nullable
        - `subject` object
        - `purchase` PriceEstimate
          - `amount` number, nullable
          - `basis` string, required
          - `source` 'model' | 'asking_listings' | 'completed_sales' | 'unavailable'
          - `evidence_count` integer
          - `explanation` string
          - `gross_amount` number, nullable
          - `fee_rate` number, nullable
          - `fee_fixed` number, nullable
          - `fee_note` string, nullable
          - `status` 'available' | 'pending' | 'unsupported'
          - `range_min` number, nullable
          - `range_max` number, nullable
          - `range_kind` string, nullable
          - `method` string
          - `method_version` string, nullable
          - `as_of` string, nullable
          - `currency` string
          - `market` string
          - `condition` string, nullable
          - `subject_version` string, nullable
          - `evidence_ids` string[]
          - `freshness` string, nullable
          - `calculation_plan` object, nullable
          - `components` PriceComponent[]
            - `currency` string, nullable
            - `exposure` object, nullable
            - `component_id` string, nullable
            - `basis` string, nullable
            - `as_of` string, nullable
            - `evidence_ids` string[]
            - `movement_status` string, nullable
            - `change_amount` number, nullable
            - `reason` string, nullable
            - `label` string, required
            - `amount` number, required
            - `note` string, required
        - `resale` PriceEstimate
          - `amount` number, nullable
          - `basis` string, required
          - `source` 'model' | 'asking_listings' | 'completed_sales' | 'unavailable'
          - `evidence_count` integer
          - `explanation` string
          - `gross_amount` number, nullable
          - `fee_rate` number, nullable
          - `fee_fixed` number, nullable
          - `fee_note` string, nullable
          - `status` 'available' | 'pending' | 'unsupported'
          - `range_min` number, nullable
          - `range_max` number, nullable
          - `range_kind` string, nullable
          - `method` string
          - `method_version` string, nullable
          - `as_of` string, nullable
          - `currency` string
          - `market` string
          - `condition` string, nullable
          - `subject_version` string, nullable
          - `evidence_ids` string[]
          - `freshness` string, nullable
          - `calculation_plan` object, nullable
          - `components` PriceComponent[]
            - `currency` string, nullable
            - `exposure` object, nullable
            - `component_id` string, nullable
            - `basis` string, nullable
            - `as_of` string, nullable
            - `evidence_ids` string[]
            - `movement_status` string, nullable
            - `change_amount` number, nullable
            - `reason` string, nullable
            - `label` string, required
            - `amount` number, required
            - `note` string, required
        - `components` PriceComponent[]
          - `currency` string, nullable
          - `exposure` object, nullable
          - `component_id` string, nullable
          - `basis` string, nullable
          - `as_of` string, nullable
          - `evidence_ids` string[]
          - `movement_status` string, nullable
          - `change_amount` number, nullable
          - `reason` string, nullable
          - `label` string, required
          - `amount` number, required
          - `note` string, required
        - `evidence` object[]
        - `assumptions` string[]
        - `requested_inputs` string[]
        - `requested_attributes` string[]
        - `insurance_note` string
        - `valuation_id` string, nullable
        - `previous_valuation` object, nullable
      - `is_gemstone` boolean
      - `scan_version` unknown
      - `main_heading` string, required
      - `sub_heading` string, required
      - `loose_gem` boolean, required
      - `specimen_form` 'faceted_stone' | 'cabochon' | 'bead_carving' | 'pearl' | 'rough_crystal' | 'mineral_specimen' | 'jewelry_mounted', nullable
      - `primary_stone` string, nullable
      - `primary_color` string, nullable
      - `gem_species` string, nullable
      - `gem_species_confidence` integer
      - `top_3_candidates` TopCandidate[]
        - `species` string, required
        - `confidence` integer, required
      - `jewelry_type` string, nullable
      - `metal_type` string, nullable
      - `piece_class` 'fine' | 'fashion', nullable
      - `metal_weight_grams` number, nullable
      - `authenticity` Authenticity
        - `state` 'ok' | 'warn', required
        - `message` string, required
        - `verify_link` string, nullable
      - `printed_claims_seen` boolean
      - `notices` ScanNotice[]
        - `code` string, required
        - `severity` 'info' | 'warn'
        - `message` string, required
      - `estimated_value` EstimatedValueV2: Lean V1-style value estimate — the model's source of truth for value. Replaces the structured three-tier reconciliation that the model was previously forced to emit. The hybrid eval (LNK-IOP, N=110) showed the tier prompt overpriced jewelry by 2x median; V1-style lean valuation recovers 80-93% within-2x accuracy on jewelry. scan_client.py derives the legacy `value` shape from this for back-compat.
        - `currency` 'USD'
        - `best` number, required
        - `range_min` number, required
        - `range_max` number, required
        - `headline_basis` string, nullable
        - `bases` object, nullable
      - `value` ValueBreakdownV2: Three-tier value breakdown (DERIVED for back-compat). Hybrid pipeline (LNK-IOP) replaced the model's structured tier output with a lean `estimated_value` range. scan_client.py derives this `value` shape from `estimated_value` so older mobile clients reading `value.hero_amount` continue to work. Tier 1 and Tier 2 are stubs ($0, empty arrays) in derived output. Tier 3 carries the full estimated value. hero_amount equals estimated_value.best. The previous hard validator (hero == tier_1 + tier_2 + tier_3) was removed because the model no longer emits tier subtotals — derivation is mechanical, validation would always pass trivially.
        - `currency` 'USD'
        - `hero_amount` number, required
        - `hero_range_min` number, required
        - `hero_range_max` number, required
        - `tier_1` ValueTier1, required
          - `label` string
          - `metals` ValueMetalLine[]
            - `type` string, required
            - `weight` string, required
            - `rate` string, required
            - `value` number, required
          - `stones` ValueStoneLine[]
            - `description` string, required
            - `value` number, required
          - `subtotal` number, required
        - `tier_2` ValueTier2, required
          - `label` string
          - `items` ValueTier2Item[]
            - `description` string, required
            - `value` number, required
          - `subtotal` number, required
        - `tier_3` ValueTier3, required
          - `label` string
          - `description` string, required
          - `value` number, required
        - `estimated_piece_value` number, required
        - `approximate_retail_price` number, required
        - `headline_basis` string, nullable
      - `analysis` Analysis, required: Paragraph 1 observes the piece, paragraph 2 places it. `context` is the market/tradition/design-period paragraph. Optional by design: the prompt is told to return null rather than reach when there is no specific tradition or trade habit tied to what was observed (an Unidentified species, a non-gem object). A missing context is a clean outcome, so it must never fail validation.
        - `description` string, required
        - `context` string, nullable
        - `specs` AnalysisSpec[], required
          - `label` string, required
          - `value` string, required
      - `comparables` ComparableListing[]
        - `price` number, nullable
        - `source_name` string, required
        - `source_url` string, required
        - `thumbnail_url` string, nullable
        - `kind` 'listing' | 'category'
        - `title` string, nullable
        - `currency` string, nullable
        - `price_source` 'product_page' | 'unavailable', nullable
        - `checked_at` string, nullable
        - `comparison_notes` string[], nullable
      - `comparables_disclaimer` string
      - `market_insights` AppSchemasScanV2MarketInsights
        - `trend` 'Rising' | 'Stable' | 'Declining', required
        - `rarity` 'Extremely Rare' | 'Rare' | 'Moderately Rare' | 'Common', required
        - `supply` 'Limited' | 'Moderate' | 'Abundant', required
      - `about` AppSchemasScanV2About
        - `history_and_lore` string, required
        - `cultural_significance` string, required
      - `composition` Composition
        - `center_stone` CompositionStone
          - `type` string, nullable
          - `carat` string, nullable
          - `color` string, nullable
          - `cut` string, nullable
          - `clarity` string, nullable
        - `accents` CompositionAccent[]
          - `type` string, nullable
          - `count` integer, nullable
        - `setting` CompositionSetting
          - `metal` string, nullable
          - `style` string, nullable
      - `origin_estimate` OriginEstimateV2
        - `likely_origin` string
        - `confidence` integer
        - `basis` string, nullable
      - `clarity_estimate` ClarityEstimateV2
        - `grade` string, required
        - `confidence` integer
      - `treatment_estimate` TreatmentEstimateV2
        - `status` string, required
        - `confidence` integer
      - `dimensions_estimate` DimensionsEstimateV2
        - `length_mm` number, nullable
        - `width_mm` number, nullable
        - `depth_mm` number, nullable
        - `confidence` integer
      - `carat_estimate` CaratEstimateV2
        - `length_mm` number, nullable
        - `width_mm` number, nullable
        - `depth_mm` number, nullable
        - `best` number, nullable
        - `range_min` number, nullable
        - `range_max` number, nullable
      - `carat_scope` string, nullable
      - `stone_quantity` integer, nullable
      - `observed_colors` object[], nullable
      - `valuation_basis` object, nullable
      - `total_carat_weight` TotalCaratWeightV2: Combined weight of every stone in a multi-stone piece [LNK-TCW]. Separate from ``carat_estimate``, which measures ONE stone via the dimensions-first method. Collapsing the two into a single unlabelled field is what let prod gems 4277 and 4278 — the same bracelet three minutes apart — report 6.00 ct and 0.12 ct from prose that agreed with itself. RANGE-ONLY BY DESIGN. Measured on 41 multi-stone prod frames, three identical-byte runs each: median spread 1.30x, p90 1.58x. A point value here would be false precision, so ``best`` must always be read alongside range_min/range_max and never rendered alone.
        - `best` number, nullable
        - `range_min` number, nullable
        - `range_max` number, nullable
        - `confidence` integer, nullable
      - `transparency_estimate` string, nullable
      - `color_grade` string, nullable
      - `color_grade_confidence` integer, nullable
      - `clarity_grade` string, nullable
      - `clarity_grade_confidence` integer, nullable
      - `cut_grade` string, nullable
      - `cut_grade_confidence` integer, nullable
      - `origin_grade` string, nullable
      - `origin_grade_confidence` integer, nullable
      - `treatment_grade` string, nullable
      - `treatment_grade_confidence` integer, nullable
      - `confidence` integer
      - `scan_quality` 'High' | 'Good' | 'Fair'
      - `alternatives` AlternativeV2[]
        - `name` string, required
        - `confidence` integer, required
      - `species_shortlist` ShortlistOption[], nullable
        - `species` string, required
        - `label` string, required
      - `id_agreement` 'confirmed' | 'family_match' | 'divergent' | 'unverified'
      - `id_agreement_basis` string, nullable
      - `identity_revision` object, nullable
      - `pricing_evidence` object, nullable
      - `pipeline_version` string, nullable
      - `unresolved` string[]
      - `stage1_species` string, nullable
      - `final_species` string, nullable
      - `photo_guidance` 'good' | 'add_angle' | 'retake'
      - `capture_issue` string, nullable
      - `value_breakdown` unknown
      - `stone_specifications` unknown
      - `reconciliation` ReconciliationTrace: Trace of the scan-consistency layer (LNK-SCANFIX). Present when an outlier was corrected against the user's same-item recent scans, or a low-confidence value was gated. Lenient by design — telemetry, never fails a scan.
        - `action` 'override' | 'low_confidence' | 'same_item_divergent' | 'same_item_species_conflict' | 'none', nullable
        - `reason` string, nullable
        - `from_species` string, nullable
        - `to_species` string, nullable
        - `same_item_ids` integer[]
      - `consensus` ConsensusTrace: Trace of the self-consistency layer (LNK-SELFCON). Records how many independent reads of the same frames were taken and what they disagreed about. Lenient by design — telemetry, never fails a scan.
        - `runs` integer
        - `species_agreed` boolean
        - `species_votes` object
        - `named_votes` integer
        - `species_conflict` boolean
        - `value_spread` number, nullable
        - `capped` boolean
        - `action` string, nullable
      - `brand_identification` BrandIdentificationBlock: Luxury-house attribution from design language / signature. [LNK-BRAND] Values are pre-normalized by app.services.brand_vocab (canonical names only, confidence clamped 0-100). Lenient here on purpose — a malformed blob must never fail a scan.
        - `brand` string, nullable
        - `product_line` string, nullable
        - `recognition_basis` string, nullable
        - `confidence` integer, nullable
        - `evidence` string, nullable
      - `watch` object, nullable
    - `scan_refunded` boolean
    - `possible_same_as` PossibleSamePiece: [LNK-RESCAN] A recent scan that looks like this piece but read a very different value. The client asks the user whether to combine the two.
      - `gemstone_id` integer, required
      - `minutes_ago` integer, required
      - `species` string, nullable
      - `best` number, nullable
      - `range_min` number, nullable
      - `range_max` number, nullable
      - `image_url` string, nullable
  - ScanV2ErrorResponse: Returned when the AI rejects the image as non-gemstone or analysis fails.
    - `is_gemstone` false
    - `scan_version` unknown
    - `error` string, required
    - `message` string, required

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/gemstoneaiapp/apis/gemstoneai-api.md) · [All operations](https://skmtc.dev/gemstoneaiapp/apis/gemstoneai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/gemstoneaiapp/apis/gemstoneai-api/revisions/fcb441f55161?raw)
