Developer Documentation

VINocr API 3.0 Documentation

Capture VIN text from uploaded images and use the response to reduce manual entry in automotive workflows.

API access uses API Consumers and JWT bearer tokens. Create credentials in your dashboard, keep Client Secrets on your server, and request an audience-specific token before calling this API. See the API access and authentication guidance.

Please be aware that the VIN output from the VINocr API may need additional verification due to potential complications in the recognition and translation process. However, proofreading the extracted VIN will be significantly faster and easier than manually typing it character by character. Additionally, to maximize recognition speed, image size plays a crucial role.

Avoid Submitting The Same Image More Than Once

Our system deducts one credit from your account balance for each XML or JSON result returned, regardless of the outcome. Therefore, please ensure your system has a mechanism in place to prevent sending the same image to the API service more than once.

REST API Description

This API uses multipart POST HTTP method. Ensure your POST is a multipart/form-data request. Five parameters and their values are available as follows.

Parameter Name Parameter Value Used in Request
JWT Bearer Token Authorization header Bearer <jwt-token> Authorization: Bearer <jwt-token>
Input Image* Image File* JPG, JPEG, PNG, GIF, BMP POST https://www.recognition.ws/vinocr/v3?saveimage=TRUE with multipart/form-data file
Save Image** saveimage** TRUE or FALSE (default: FALSE) POST https://www.recognition.ws/vinocr/v3?saveimage=TRUE
VIN Decode*** vindecode*** TRUE or FALSE (default: FALSE) POST https://www.recognition.ws/vinocr/v3?saveimage=TRUE&vindecode=TRUE
Data Format**** format**** XML or JSON (default: XML) POST https://www.recognition.ws/vinocr/v3?saveimage=TRUE&vindecode=TRUE&format=JSON
Request a JWT first from POST https://identity.vinquery.com/connect/token using an API Consumer with audience vinquery:api:vinocr.
* Only one image file is allowed in a POST request. A POST request with multiple files would be rejected immediately. Only the first found VIN will be processed if an input image contains more than one VIN.
** Set parameter "saveimage" to "TRUE" if you'd like the service to save the input image so that later you can log in to your account to review it. Set the value of this parameter to "FALSE" otherwise.
*** Set parameter "vindecode" to "TRUE" if you'd like the service to retrieve make/model/year for the captured VIN code. Set the value of this parameter to "FALSE" otherwise.
**** Set parameter "format" to "JSON" if you'd like the service to send data in JSON format. If you don't include this parameter at all in the API URL, or explicitly set the value of this parameter to "XML" to get data in XML format.

 

Input Image Specs

VIN characters have to meet the specification of at least 25 pixels high characters. In general, OCR can decode characters lower than this requirement but not at the extremely high(99.8%) accuracies required.

Input Image Specs
Height of the VIN portion in an input image 20 - 150 pixels
Width of the VIN portion in an input image 80 - 400 pixels
Length of a VIN 17 characters
Size of Input Image < 2.0 MB

Output in XML or JSON

  • VIN: VIN number in ASCII
  • Position of the VIN portion in an input image: Top(pixels), Left(pixels), Width(pixels), Height(pixels). The origin(0,0) is the top-left corner of an input image.
VINocr without VINdecode output in XML format: POST https://www.recognition.ws/vinocr/v3 with Authorization: Bearer <jwt-token>
XML

    <?xml version="1.0" encoding="utf-8" standalone="yes"?>
    <VINocr Version="3.0." Date="12/14/2025 3:23:58 PM" Status="SUCCESS">
      <VIN_Captured>XXXXXXXXXXXXXXXXX</VIN_Captured>
      <Left>299</Left>
      <Top>429</Top>
      <Width>450</Width>
      <Height>42</Height>
    </VINocr>
        
VINocr without VINdecode output in JSON format: POST https://www.recognition.ws/vinocr/v3?format=JSON with Authorization: Bearer <jwt-token>
JSON

    {
      "service": "vinocr",
      "version": "3.0",
      "date": "3/7/2026 3:53:26 PM",
      "status": "SUCCESS",
      "vin_captured": "XXXXXXXXXXXXXXXXX",
      "left": 299.0,
      "top": 429.0,
      "width": 450.0,
      "height": 42.0
    }
        
VINocr with VINdecode output in XML format: POST https://www.recognition.ws/vinocr/v3?vindecode=TRUE with Authorization: Bearer <jwt-token>
XML

    <?xml version="1.0" encoding="utf-8" standalone="yes"?>
    <VINocr Version="3.0" Date="8/14/2025 10:13:32 AM" Status="SUCCESS">
      <VIN_Captured>XXXXXXXXXXXXXXXXX</VIN_Captured>
         <VINdecode Status="SUCCESS">
             <Make>Audi</Make>
             <Model>A4</Model>
             <Year>2010</Year>
         </VINdecode>
      <Left>299</Left>
      <Top>429</Top>
      <Width>450</Width>
      <Height>42</Height>
    </VINocr>
        
VINocr with VINdecode output in JSON format: POST https://www.recognition.ws/vinocr/v3?vindecode=TRUE&format=JSON with Authorization: Bearer <jwt-token>
JSON

    {
      "service": "vinocr",
      "version": "3.0",
      "date": "3/7/2026 3:53:26 PM",
      "status": "SUCCESS",
      "vin_captured": "XXXXXXXXXXXXXXXXX",
      "vindecode": {
        "status": "SUCCESS",
        "make": "Audi",
        "model": "A4",
        "year": 2010
      },
      "left": 299.0,
      "top": 429.0,
      "width": 450.0,
      "height": 42.0
    }
        

Error Codes

Following errors can occur when the system attempts to locate & read a VIN code in a given image:
Error Code (Key) Description (Value)
0 Database Errors.
23 Insufficient balance for Recognition.
25 No image has been uploaded.
26 Recognition failed.
27 Unknown error(s) occured.
29 One or more required parameters missing.
XML

    // XML

    <?xml version="1.0" encoding="utf-8" standalone="yes"?>
    <VINocr version="3.0" Date="5/28/2026 1:39:10 PM" Status="FAILED">
      <Message Key="0" Value="Database Errors." />
    </VINocr>
        
JSON


    // JSON
    {
      "service": "vinocr",
      "version": "3.0",
      "date": "03/08/26 3:23:27 PM",
      "status": "FAILED",
      "message_key": 0,
      "message": "Database Errors." 
    }
        
Following errors can occur when the system attempts to decode the captured VIN:
Error Code (Key) Description (Value)
0 Database Errors.
2 Valid VIN number. However, no data available for it at this moment.
3 Invalid VIN number: This VIN number did not pass checksum test
4 A valid VIN number must be exactly 17 digits.
5 Invalid VIN number: This VIN number contains invalid letters: I, O or Q.
7 Invalid VIN number: The last 4 digits of a VIN number must be numeric..
14 Invalid VIN number: The 10th digit of a VIN number cannot be letter U, letter Z or number 0.
16 A VIN can only contain alphanumeric characters.
XML

    // XML

    <?xml version="1.0" encoding="utf-8" standalone="yes"?>
    <VINocr version="3.0" Date="9/24/2025 1:43:16 AM" Status="SUCCESS">
      <VIN_Captured>1FTRF02W24KXXXXXX</VIN_Captured>
        <VINdecode>
            <Message Key="3" Value="Invalid VIN number: This VIN number did not pass checksum test." />
        </VINdecode>
        <Left>40</Left>
        <Top>60</Top>
        <Width>195</Width>
        <Height>45</Height>
    </VINocr>
        
JSON

    // JSON

    {
      "service": "vinocr",
      "version": "3.0",
      "date": "03/08/26 3:23:27 PM",
      "status": "SUCCESS",
      "vin_captured": "1FTRF02W24KXXXXXX<",
      "vindecode": {
        "status": "FAILED",
        "message_key": 3,
        "message": "Invalid VIN number: This VIN number did not pass checksum test."
      },
      "left": 40.0,
      "top": 60.0,
      "width": 195.0,
      "height": 45.0
    }
        

Coding Examples (JWT Authentication)

These server-side examples keep API Consumer credentials private, cache and reuse a valid VINocr JWT, renew shortly before expiration, upload the image in the inputimage multipart field, and perform one refresh-and-retry after an unexpected 401. Each retry creates a fresh multipart request body. The implementations follow the Server-Side Proxy Guide.

JavaScript
import express from "express";

const app = express();

// Store API Consumer credentials only in trusted server-side configuration.
// These values must never be shipped to browser JavaScript, mobile apps, or public source code.
const clientId = process.env.VINQUERY_VINOCR_CLIENT_ID;
const clientSecret = process.env.VINQUERY_VINOCR_CLIENT_SECRET;
const audience = "vinquery:api:vinocr";
const identityTokenUrl = "https://identity.vinquery.com/connect/token";
const vinocrUrl = "https://www.recognition.ws/vinocr/v3";

// Renew shortly before expiry so a user request is not made with a nearly expired token.
const refreshBufferMs = 60 * 1000;

// This in-memory cache is process-local. In a multi-instance deployment, each instance
// should maintain its own token cache rather than asking Identity for every API call.
let cachedToken = null;

// A shared promise prevents simultaneous requests from all refreshing the token at once.
let refreshInFlight = null;

function tokenIsUsable() {
  return cachedToken && Date.now() < cachedToken.expiresAtMs - refreshBufferMs;
}

async function getJwtToken() {
  // Reuse the cached JWT when it is still comfortably valid.
  if (tokenIsUsable()) {
    return cachedToken.jwtToken;
  }

  // If another request is already refreshing, wait for the same refresh operation.
  if (refreshInFlight) {
    return refreshInFlight;
  }

  refreshInFlight = (async () => {
    if (!clientId || !clientSecret) {
      throw new Error("VINquery API Consumer credentials are not configured.");
    }

    const response = await fetch(identityTokenUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ clientId, clientSecret, audience })
    });

    if (!response.ok) {
      throw new Error(`Identity token request failed with HTTP ${response.status}.`);
    }

    // Current Identity contract returns jwtToken, expiresIn, and expiresUtc.
    const token = await response.json();
    if (!token.jwtToken || !token.expiresUtc) {
      throw new Error("Identity response did not include jwtToken and expiresUtc.");
    }

    cachedToken = {
      jwtToken: token.jwtToken,
      expiresAtMs: Date.parse(token.expiresUtc)
    };

    return cachedToken.jwtToken;
  })().finally(() => {
    refreshInFlight = null;
  });

  return refreshInFlight;
}

function invalidateRejectedToken(rejectedToken) {
  // Do not erase a newer token that another request may already have refreshed.
  if (cachedToken?.jwtToken === rejectedToken) {
    cachedToken = null;
  }
}

app.post("/api/vinocr", express.raw({ type: ["image/*", "application/octet-stream"], limit: "10mb" }), async (req, res) => {
  try {
    const imageBytes = req.body;
    if (!Buffer.isBuffer(imageBytes) || imageBytes.length === 0) {
      return res.status(400).json({ message: "An image is required." });
    }

    const callVinOcr = (token) => {
      // Build a fresh multipart body for the initial call and for the one retry.
      const form = new FormData();
      form.append("inputimage", new Blob([imageBytes], { type: req.type || "image/jpeg" }), "vin-image.jpg");
      return fetch(`${vinocrUrl}?format=JSON`, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
        body: form
      });
    };

    let jwtToken = await getJwtToken();
    let apiResponse = await callVinOcr(jwtToken);

    // A token can be revoked before expiresUtc. Invalidate and retry exactly once.
    if (apiResponse.status === 401) {
      invalidateRejectedToken(jwtToken);
      jwtToken = await getJwtToken();
      apiResponse = await callVinOcr(jwtToken);
    }

    res.status(apiResponse.status).type("application/json").send(await apiResponse.text());
  } catch (error) {
    // Return a safe error. Log full details server-side, not in the browser response.
    console.error("VINocr proxy failed", error);
    res.status(502).json({ message: "VINquery request could not be completed." });
  }
});
TypeScript
import express, { Request, Response } from "express";

const app = express();

type TokenResponse = {
  jwtToken: string;
  expiresIn: number;
  expiresUtc: string;
};

type CachedToken = {
  jwtToken: string;
  expiresAtMs: number;
};

// Credentials and audience are server-side configuration. Never expose them to the client.
const clientId = process.env.VINQUERY_VINOCR_CLIENT_ID || "";
const clientSecret = process.env.VINQUERY_VINOCR_CLIENT_SECRET || "";
const audience = "vinquery:api:vinocr";
const refreshBufferMs = 60_000;
let cachedToken: CachedToken | null = null;
let refreshInFlight: Promise<string> | null = null;

function cachedTokenIsValid(): boolean {
  return !!cachedToken && Date.now() < cachedToken.expiresAtMs - refreshBufferMs;
}

async function requestFreshToken(): Promise<string> {
  if (!clientId || !clientSecret) {
    throw new Error("VINquery API Consumer credentials are missing.");
  }

  const response = await fetch("https://identity.vinquery.com/connect/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ clientId, clientSecret, audience })
  });

  if (!response.ok) {
    throw new Error(`Identity token request failed with HTTP ${response.status}.`);
  }

  const token = (await response.json()) as TokenResponse;
  if (!token.jwtToken || !token.expiresUtc) {
    throw new Error("Identity response did not include jwtToken and expiresUtc.");
  }

  cachedToken = {
    jwtToken: token.jwtToken,
    expiresAtMs: Date.parse(token.expiresUtc)
  };

  return cachedToken.jwtToken;
}

async function getJwtToken(): Promise<string> {
  // Use the cached token until it is close to expiration.
  if (cachedTokenIsValid()) {
    return cachedToken!.jwtToken;
  }

  // Share one refresh promise across concurrent requests.
  refreshInFlight ??= requestFreshToken().finally(() => {
    refreshInFlight = null;
  });

  return refreshInFlight;
}

function invalidateRejectedToken(rejectedToken: string): void {
  // Preserve a newer token installed concurrently by another request.
  if (cachedToken?.jwtToken === rejectedToken) {
    cachedToken = null;
  }
}

app.post("/api/vinocr", express.raw({ type: ["image/*", "application/octet-stream"], limit: "10mb" }), async (req: Request, res: Response) => {
  try {
    const imageBytes = req.body as Buffer;
    if (!Buffer.isBuffer(imageBytes) || imageBytes.length === 0) {
      return res.status(400).json({ message: "An image is required." });
    }

    const callVinOcr = (token: string): Promise<globalThis.Response> => {
      const form = new FormData();
      form.append("inputimage", new Blob([imageBytes], { type: req.type || "image/jpeg" }), "vin-image.jpg");
      return fetch("https://www.recognition.ws/vinocr/v3?format=JSON", {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
        body: form
      });
    };

    let jwtToken = await getJwtToken();
    let apiResponse = await callVinOcr(jwtToken);

    if (apiResponse.status === 401) {
      invalidateRejectedToken(jwtToken);
      jwtToken = await getJwtToken();
      apiResponse = await callVinOcr(jwtToken);
    }

    return res.status(apiResponse.status).type("application/json").send(await apiResponse.text());
  } catch (error) {
    console.error("VINocr proxy failed", error);
    return res.status(502).json({ message: "VINquery request could not be completed." });
  }
});
Python
from datetime import datetime, timezone, timedelta
import os
import requests
from threading import Lock
from fastapi import FastAPI, File, HTTPException, Response, UploadFile

app = FastAPI()

# API Consumer credentials stay in trusted server-side configuration.
CLIENT_ID = os.environ.get("VINQUERY_VINOCR_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("VINQUERY_VINOCR_CLIENT_SECRET", "")
AUDIENCE = "vinquery:api:vinocr"
IDENTITY_TOKEN_URL = "https://identity.vinquery.com/connect/token"
VINOCR_URL = "https://www.recognition.ws/vinocr/v3"
REFRESH_BUFFER = timedelta(seconds=60)

_cached_token = None
_token_expires_at = datetime.min.replace(tzinfo=timezone.utc)
_refresh_lock = Lock()

def _token_is_usable() -> bool:
    # Refresh before the exact expiry time so normal traffic does not hit an expired token.
    return bool(_cached_token) and datetime.now(timezone.utc) < (_token_expires_at - REFRESH_BUFFER)

def get_jwt_token() -> str:
    global _cached_token, _token_expires_at

    if _token_is_usable():
        return _cached_token

    # The lock prevents concurrent requests from creating duplicate token refreshes.
    with _refresh_lock:
        if _token_is_usable():
            return _cached_token

        if not CLIENT_ID or not CLIENT_SECRET:
            raise RuntimeError("VINquery API Consumer credentials are missing.")

        token_response = requests.post(
            IDENTITY_TOKEN_URL,
            json={"clientId": CLIENT_ID, "clientSecret": CLIENT_SECRET, "audience": AUDIENCE},
            timeout=15
        )
        token_response.raise_for_status()

        # Current Identity response contract: jwtToken, expiresIn, expiresUtc.
        token_json = token_response.json()
        if not token_json.get("jwtToken") or not token_json.get("expiresUtc"):
            raise RuntimeError("Identity response did not include jwtToken and expiresUtc.")

        _cached_token = token_json["jwtToken"]
        _token_expires_at = datetime.fromisoformat(token_json["expiresUtc"].replace("Z", "+00:00"))
        return _cached_token

def invalidate_rejected_token(rejected_token: str) -> None:
    global _cached_token, _token_expires_at
    # Compare under the same lock so a concurrent refresh is never discarded.
    with _refresh_lock:
        if _cached_token == rejected_token:
            _cached_token = None
            _token_expires_at = datetime.min.replace(tzinfo=timezone.utc)

@app.post("/api/vinocr")
async def vinocr(inputimage: UploadFile = File(...)):
    try:
        image_bytes = await inputimage.read()
        if not image_bytes:
            raise HTTPException(status_code=400, detail="An image is required.")

        def call_vinocr(token: str):
            # A new files dictionary creates a fresh multipart body for each attempt.
            return requests.post(
                VINOCR_URL,
                params={"format": "JSON"},
                files={"inputimage": (inputimage.filename or "vin-image.jpg", image_bytes, inputimage.content_type or "image/jpeg")},
                headers={"Authorization": f"Bearer {token}"},
                timeout=30
            )

        jwt_token = get_jwt_token()
        api_response = call_vinocr(jwt_token)

        if api_response.status_code == 401:
            api_response.close()
            invalidate_rejected_token(jwt_token)
            jwt_token = get_jwt_token()
            api_response = call_vinocr(jwt_token)

        return Response(
            content=api_response.text,
            status_code=api_response.status_code,
            media_type="application/json"
        )
    except HTTPException:
        raise
    except Exception as exc:
        # Log the detailed exception server-side and return a safe message to the client.
        print(f"VINocr proxy failed: {exc}")
        raise HTTPException(status_code=502, detail="VINquery request could not be completed.")
PHP
<?php
// This example uses APCu for process-local in-memory caching and a lock file to
// prevent duplicate token refreshes. Keep credentials in server environment variables.
$clientId = getenv("VINQUERY_VINOCR_CLIENT_ID");
$clientSecret = getenv("VINQUERY_VINOCR_CLIENT_SECRET");
$audience = "vinquery:api:vinocr";
$identityTokenUrl = "https://identity.vinquery.com/connect/token";
$vinocrUrl = "https://www.recognition.ws/vinocr/v3";
$refreshBufferSeconds = 60;

function getJwtToken() {
    global $clientId, $clientSecret, $audience, $identityTokenUrl, $refreshBufferSeconds;

    if (!function_exists("apcu_fetch") || !function_exists("apcu_store")) {
        throw new RuntimeException("APCu must be enabled for this in-memory token cache example.");
    }

    $cached = apcu_fetch("vinquery_vinocr_jwt");
    if ($cached && time() < ($cached["expiresAt"] - $refreshBufferSeconds)) {
        return $cached["jwtToken"];
    }

    $lock = fopen(sys_get_temp_dir() . "/vinquery-vinocr-token.lock", "c");
    if (!$lock || !flock($lock, LOCK_EX)) {
        throw new RuntimeException("Could not acquire token refresh lock.");
    }

    try {
        // Re-check inside the lock because another request may have refreshed the token.
        $cached = apcu_fetch("vinquery_vinocr_jwt");
        if ($cached && time() < ($cached["expiresAt"] - $refreshBufferSeconds)) {
            return $cached["jwtToken"];
        }

        if (!$clientId || !$clientSecret) {
            throw new RuntimeException("VINquery API Consumer credentials are missing.");
        }

        $tokenPayload = json_encode([
            "clientId" => $clientId,
            "clientSecret" => $clientSecret,
            "audience" => $audience
        ]);

        $curl = curl_init($identityTokenUrl);
        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
            CURLOPT_POSTFIELDS => $tokenPayload,
            CURLOPT_TIMEOUT => 15
        ]);

        $body = curl_exec($curl);
        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        curl_close($curl);

        if ($status < 200 || $status >= 300) {
            throw new RuntimeException("Identity token request failed with HTTP " . $status . ".");
        }

        // Current Identity response contract: jwtToken, expiresIn, expiresUtc.
        $token = json_decode($body, true);
        if (empty($token["jwtToken"]) || empty($token["expiresUtc"])) {
            throw new RuntimeException("Identity response did not include jwtToken and expiresUtc.");
        }

        $expiresAt = strtotime($token["expiresUtc"]);
        apcu_store("vinquery_vinocr_jwt", [
            "jwtToken" => $token["jwtToken"],
            "expiresAt" => $expiresAt
        ], max(1, $expiresAt - time()));

        return $token["jwtToken"];
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
}

function invalidateRejectedToken(string $rejectedToken): void {
    $lock = fopen(sys_get_temp_dir() . "/vinquery-vinocr-token.lock", "c");
    if (!$lock || !flock($lock, LOCK_EX)) {
        throw new RuntimeException("Could not acquire token refresh lock.");
    }
    try {
        $cached = apcu_fetch("vinquery_vinocr_jwt");
        // Delete only the rejected token while holding the same lock used by refresh.
        if ($cached && hash_equals($cached["jwtToken"], $rejectedToken)) {
            apcu_delete("vinquery_vinocr_jwt");
        }
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
}

function callVinOcr(string $vinocrUrl, string $imagePath, string $fileName, string $contentType, string $jwtToken): array {
    $curl = curl_init($vinocrUrl . "?format=JSON");
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $jwtToken],
        CURLOPT_POSTFIELDS => ["inputimage" => curl_file_create($imagePath, $contentType, $fileName)],
        CURLOPT_TIMEOUT => 30
    ]);
    $body = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    curl_close($curl);
    return [$status, $body];
}

try {
    if (empty($_FILES["inputimage"]["tmp_name"])) {
        http_response_code(400);
        header("Content-Type: application/json");
        echo json_encode(["message" => "An image is required."]);
        exit;
    }
    $imagePath = $_FILES["inputimage"]["tmp_name"];
    $fileName = $_FILES["inputimage"]["name"] ?: "vin-image.jpg";
    $contentType = $_FILES["inputimage"]["type"] ?: "image/jpeg";

    $jwtToken = getJwtToken();
    [$apiStatus, $apiBody] = callVinOcr($vinocrUrl, $imagePath, $fileName, $contentType, $jwtToken);

    if ($apiStatus === 401) {
        invalidateRejectedToken($jwtToken);
        $jwtToken = getJwtToken();
        [$apiStatus, $apiBody] = callVinOcr($vinocrUrl, $imagePath, $fileName, $contentType, $jwtToken);
    }

    http_response_code($apiStatus);
    header("Content-Type: application/json");
    echo $apiBody;
} catch (Throwable $ex) {
    error_log("VINocr proxy failed: " . $ex->getMessage());
    http_response_code(502);
    header("Content-Type: application/json");
    echo json_encode(["message" => "VINquery request could not be completed."]);
}
?>
Go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "mime/multipart"
    "net/http"
    "os"
    "path/filepath"
    "sync"
    "time"
)

const (
    audience         = "vinquery:api:vinocr"
    identityTokenURL = "https://identity.vinquery.com/connect/token"
    vinocrURL     = "https://www.recognition.ws/vinocr/v3"
    refreshBuffer    = time.Minute
)

type tokenResponse struct {
    JwtToken   string    `json:"jwtToken"`
    ExpiresIn  int       `json:"expiresIn"`
    ExpiresUtc time.Time `json:"expiresUtc"`
}

type tokenCache struct {
    sync.Mutex
    jwtToken  string
    expiresAt time.Time
}

var cache tokenCache

func getJWTToken() (string, error) {
    cache.Lock()
    defer cache.Unlock()

    // Reuse the JWT until it is close to expiration.
    if cache.jwtToken != "" && time.Now().UTC().Before(cache.expiresAt.Add(-refreshBuffer)) {
        return cache.jwtToken, nil
    }

    clientID := os.Getenv("VINQUERY_VINOCR_CLIENT_ID")
    clientSecret := os.Getenv("VINQUERY_VINOCR_CLIENT_SECRET")
    if clientID == "" || clientSecret == "" {
        return "", fmt.Errorf("VINquery API Consumer credentials are missing")
    }

    payload, _ := json.Marshal(map[string]string{
        "clientId":     clientID,
        "clientSecret": clientSecret,
        "audience":     audience,
    })

    req, _ := http.NewRequest("POST", identityTokenURL, bytes.NewReader(payload))
    req.Header.Set("Content-Type", "application/json")

    response, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer response.Body.Close()

    if response.StatusCode < 200 || response.StatusCode >= 300 {
        return "", fmt.Errorf("Identity token request failed with HTTP %d", response.StatusCode)
    }

    var token tokenResponse
    if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
        return "", err
    }
    if token.JwtToken == "" || token.ExpiresUtc.IsZero() {
        return "", fmt.Errorf("Identity response did not include jwtToken and expiresUtc")
    }

    cache.jwtToken = token.JwtToken
    cache.expiresAt = token.ExpiresUtc
    return cache.jwtToken, nil
}

func invalidateRejectedToken(rejectedToken string) {
    cache.Lock()
    defer cache.Unlock()
    // Keep a newer token if another request refreshed while this call was running.
    if cache.jwtToken == rejectedToken {
        cache.jwtToken = ""
        cache.expiresAt = time.Time{}
    }
}

func callVinOcr(imageBytes []byte, fileName, jwtToken string) (*http.Response, error) {
    var body bytes.Buffer
    writer := multipart.NewWriter(&body)
    part, err := writer.CreateFormFile("inputimage", filepath.Base(fileName))
    if err != nil { return nil, err }
    if _, err = part.Write(imageBytes); err != nil { return nil, err }
    writer.Close()

    request, err := http.NewRequest("POST", vinocrURL+"?format=JSON", &body)
    if err != nil { return nil, err }
    request.Header.Set("Authorization", "Bearer "+jwtToken)
    request.Header.Set("Content-Type", writer.FormDataContentType())
    return http.DefaultClient.Do(request)
}

func vinOcrHandler(w http.ResponseWriter, r *http.Request) {
    file, header, err := r.FormFile("inputimage")
    if err != nil {
        http.Error(w, "An image is required.", http.StatusBadRequest)
        return
    }
    defer file.Close()
    imageBytes, err := io.ReadAll(file)
    if err != nil || len(imageBytes) == 0 {
        http.Error(w, "An image is required.", http.StatusBadRequest)
        return
    }

    jwtToken, err := getJWTToken()
    if err != nil {
        fmt.Println("VINocr token failure:", err)
        http.Error(w, "VINquery request could not be completed.", http.StatusBadGateway)
        return
    }

    apiResponse, err := callVinOcr(imageBytes, header.Filename, jwtToken)
    if err != nil {
        fmt.Println("VINocr API failure:", err)
        http.Error(w, "VINquery request could not be completed.", http.StatusBadGateway)
        return
    }

    if apiResponse.StatusCode == http.StatusUnauthorized {
        io.Copy(io.Discard, apiResponse.Body)
        apiResponse.Body.Close()
        invalidateRejectedToken(jwtToken)

        jwtToken, err = getJWTToken()
        if err != nil {
            http.Error(w, "VINquery request could not be completed.", http.StatusBadGateway)
            return
        }
        apiResponse, err = callVinOcr(imageBytes, header.Filename, jwtToken)
        if err != nil {
            http.Error(w, "VINquery request could not be completed.", http.StatusBadGateway)
            return
        }
    }
    defer apiResponse.Body.Close()

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(apiResponse.StatusCode)
    io.Copy(w, apiResponse.Body)
}
C#
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();

// Register one token provider for the application process. It owns the in-memory
// JWT cache and prevents every request from calling the Identity service.
builder.Services.AddSingleton<VinqueryTokenProvider>();

var app = builder.Build();

app.MapPost("/api/vinocr", async (
    IFormFile inputimage,
    IHttpClientFactory httpClientFactory,
    VinqueryTokenProvider tokenProvider) =>
{
    if (inputimage is null || inputimage.Length == 0)
    {
        return Results.BadRequest(new { message = "An image is required." });
    }

    try
    {
        await using var imageStream = inputimage.OpenReadStream();
        using var imageBuffer = new MemoryStream();
        await imageStream.CopyToAsync(imageBuffer);
        var imageBytes = imageBuffer.ToArray();

        async Task<HttpResponseMessage> CallVinOcrAsync(string token)
        {
            using var upload = new MultipartFormDataContent();
            var image = new ByteArrayContent(imageBytes);
            image.Headers.ContentType = new MediaTypeHeaderValue(inputimage.ContentType ?? "image/jpeg");
            upload.Add(image, "inputimage", inputimage.FileName ?? "vin-image.jpg");

            using var request = new HttpRequestMessage(HttpMethod.Post, "https://www.recognition.ws/vinocr/v3?format=JSON")
            {
                Content = upload
            };
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
            return await httpClientFactory.CreateClient().SendAsync(request);
        }

        var jwtToken = await tokenProvider.GetJwtTokenAsync();
        using var response = await CallVinOcrAsync(jwtToken);
        if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
        {
            tokenProvider.InvalidateRejectedToken(jwtToken);
            jwtToken = await tokenProvider.GetJwtTokenAsync();

            using var retryResponse = await CallVinOcrAsync(jwtToken);
            return Results.Content(
                await retryResponse.Content.ReadAsStringAsync(),
                "application/json",
                statusCode: (int)retryResponse.StatusCode);
        }

        return Results.Content(
            await response.Content.ReadAsStringAsync(),
            "application/json",
            statusCode: (int)response.StatusCode);
    }
    catch (Exception ex)
    {
        app.Logger.LogError(ex, "VINocr proxy failed.");
        return Results.Problem("VINquery request could not be completed.", statusCode: 502);
    }
});

app.Run();

public sealed class VinqueryTokenProvider
{
    private static readonly TimeSpan RefreshBuffer = TimeSpan.FromSeconds(60);
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IConfiguration _configuration;
    private readonly SemaphoreSlim _refreshLock = new(1, 1);
    private TokenResponse? _cachedToken;

    public VinqueryTokenProvider(IHttpClientFactory httpClientFactory, IConfiguration configuration)
    {
        _httpClientFactory = httpClientFactory;
        _configuration = configuration;
    }

    public async Task<string> GetJwtTokenAsync()
    {
        if (TokenIsUsable())
        {
            return _cachedToken!.JwtToken;
        }

        await _refreshLock.WaitAsync();
        try
        {
            if (TokenIsUsable())
            {
                return _cachedToken!.JwtToken;
            }

            var clientId = _configuration["VINquery:VINocr:ClientId"];
            var clientSecret = _configuration["VINquery:VINocr:ClientSecret"];
            if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
            {
                throw new InvalidOperationException("VINquery API Consumer credentials are missing.");
            }

            var response = await _httpClientFactory.CreateClient().PostAsJsonAsync(
                "https://identity.vinquery.com/connect/token",
                new
                {
                    clientId,
                    clientSecret,
                    audience = "vinquery:api:vinocr"
                });

            response.EnsureSuccessStatusCode();
            _cachedToken = await response.Content.ReadFromJsonAsync<TokenResponse>()
                ?? throw new InvalidOperationException("Identity token response could not be parsed.");

            if (string.IsNullOrWhiteSpace(_cachedToken.JwtToken))
            {
                throw new InvalidOperationException("Identity response did not include jwtToken.");
            }

            return _cachedToken.JwtToken;
        }
        finally
        {
            _refreshLock.Release();
        }
    }

    public void InvalidateRejectedToken(string rejectedToken)
    {
        // Atomic compare/exchange prevents an old 401 from deleting a newer token.
        var cached = _cachedToken;
        if (cached is not null &&
            string.Equals(cached.JwtToken, rejectedToken, StringComparison.Ordinal))
        {
            Interlocked.CompareExchange(ref _cachedToken, null, cached);
        }
    }

    private bool TokenIsUsable()
    {
        // Use expiresUtc from the current Identity response and refresh before
        // exact expiry to avoid edge-of-expiration request failures.
        return _cachedToken is not null
            && !string.IsNullOrWhiteSpace(_cachedToken.JwtToken)
            && DateTimeOffset.UtcNow < _cachedToken.ExpiresUtc.Subtract(RefreshBuffer);
    }
}

public sealed class TokenResponse
{
    [JsonPropertyName("jwtToken")]
    public string JwtToken { get; set; } = "";

    [JsonPropertyName("expiresIn")]
    public int ExpiresIn { get; set; }

    [JsonPropertyName("expiresUtc")]
    public DateTimeOffset ExpiresUtc { get; set; }
}
Java
@RestController
public class VinOcrProxyController {
    private final RestClient restClient = RestClient.create();

    // This lock prevents multiple simultaneous requests from all refreshing the
    // same JWT at the same time. The cache is intentionally process-local.
    private final ReentrantLock refreshLock = new ReentrantLock();
    private final Duration refreshBuffer = Duration.ofSeconds(60);
    private volatile TokenResponse cachedToken;

    @PostMapping(value = "/api/vinocr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<String> vinOcr(@RequestPart("inputimage") MultipartFile inputimage) {
        if (inputimage == null || inputimage.isEmpty()) {
            return ResponseEntity.badRequest().body("{\"message\":\"An image is required.\"}");
        }

        try {
            byte[] imageBytes = inputimage.getBytes();
            String fileName = inputimage.getOriginalFilename() == null ? "vin-image.jpg" : inputimage.getOriginalFilename();
            String jwtToken = getJwtToken();
            ResponseEntity<String> response = callVinOcr(imageBytes, fileName, jwtToken);

            if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
                invalidateRejectedToken(jwtToken);
                jwtToken = getJwtToken();
                response = callVinOcr(imageBytes, fileName, jwtToken);
            }

            return ResponseEntity.status(response.getStatusCode())
                .contentType(MediaType.APPLICATION_JSON)
                .body(response.getBody());
        } catch (Exception ex) {
            // Log full details server-side and return a safe message to the client.
            System.err.println("VINocr proxy failed: " + ex.getMessage());
            return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
                .body("{\"message\":\"VINquery request could not be completed.\"}");
        }
    }

    private String getJwtToken() {
        if (tokenIsUsable()) {
            return cachedToken.jwtToken();
        }

        refreshLock.lock();
        try {
            if (tokenIsUsable()) {
                return cachedToken.jwtToken();
            }

            String clientId = System.getenv("VINQUERY_VINOCR_CLIENT_ID");
            String clientSecret = System.getenv("VINQUERY_VINOCR_CLIENT_SECRET");
            if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) {
                throw new IllegalStateException("VINquery API Consumer credentials are missing.");
            }

            Map<String, String> payload = Map.of(
                "clientId", clientId,
                "clientSecret", clientSecret,
                "audience", "vinquery:api:vinocr"
            );

            cachedToken = restClient.post()
                .uri("https://identity.vinquery.com/connect/token")
                .contentType(MediaType.APPLICATION_JSON)
                .body(payload)
                .retrieve()
                .body(TokenResponse.class);

            if (cachedToken == null || cachedToken.jwtToken() == null || cachedToken.jwtToken().isBlank()) {
                throw new IllegalStateException("Identity response did not include jwtToken.");
            }

            return cachedToken.jwtToken();
        } finally {
            refreshLock.unlock();
        }
    }

    private void invalidateRejectedToken(String rejectedToken) {
        refreshLock.lock();
        try {
            // Do not invalidate a newer token installed by another request.
            if (cachedToken != null && cachedToken.jwtToken().equals(rejectedToken)) {
                cachedToken = null;
            }
        } finally {
            refreshLock.unlock();
        }
    }

    private ResponseEntity<String> callVinOcr(byte[] imageBytes, String fileName, String jwtToken) {
        MultipartBodyBuilder upload = new MultipartBodyBuilder();
        upload.part("inputimage", new ByteArrayResource(imageBytes) {
            @Override public String getFilename() { return fileName; }
        });

        // A new multipart body is built for the initial request and the one retry.
        return restClient.post()
            .uri("https://www.recognition.ws/vinocr/v3?format=JSON")
            .header("Authorization", "Bearer " + jwtToken)
            .contentType(MediaType.MULTIPART_FORM_DATA)
            .body(upload.build())
            .exchange((request, response) -> ResponseEntity
                .status(response.getStatusCode())
                .headers(response.getHeaders())
                .body(new String(response.getBody().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)));
    }

    private boolean tokenIsUsable() {
        // Use expiresUtc from Identity and renew shortly before expiration.
        return cachedToken != null
            && cachedToken.jwtToken() != null
            && Instant.now().isBefore(cachedToken.expiresUtc().minus(refreshBuffer));
    }

    // Current Identity response contract: jwtToken, expiresIn, expiresUtc.
    public record TokenResponse(String jwtToken, int expiresIn, Instant expiresUtc) { }
}