Server-Side Proxy Guide
Use a server-side proxy to keep API Consumer credentials private, request JWT bearer tokens safely, call VINquery APIs, and return clean responses to your applications.
Why a Server-Side Proxy Is Required
API Consumers use a Client ID and Client Secret to request JWT bearer tokens from the VINquery Identity service. The Client Secret is a private credential and must never be sent to browsers, embedded in HTML, committed to public source code, or stored in mobile or desktop applications.
The proxy gives you one trusted place to store credentials, cache tokens, normalize errors, log requests, apply rate limits, and keep integrations consistent across your applications.
Recommended Architecture
The recommended flow is:
- Client application sends a request to your server-side proxy.
- Your proxy obtains or reuses a cached JWT bearer token from
https://identity.vinquery.com/connect/token. - Your proxy calls the appropriate VINquery API with
Authorization: Bearer <jwt-token>. - Your proxy returns the API response to the client application.
| Layer | Responsibility |
|---|---|
| Browser, desktop, or mobile app | Collects user input and calls your proxy only. |
| Server-side proxy | Stores credentials, obtains tokens, calls VINquery APIs, and returns responses. |
| VINquery Identity service | Issues JWT bearer tokens for the requested audience. |
| VINquery APIs | Validate the bearer token and perform the requested service. |
Secure Storage of Client ID and Client Secret
Store each API Consumer credential pair in server-side configuration or a secret store. Use a separate API Consumer for each production application, environment, or API audience where operational separation is useful.
Recommended
- Environment variables
- Cloud secret stores
- Server-side encrypted configuration
- Restricted deployment variables
Avoid
- Browser JavaScript
- HTML source
- Mobile app bundles
- Public repositories or logs
Requesting, Caching, and Renewing JWT Tokens
Your proxy should request a token from the Identity service only when needed, then cache it server-side until shortly before it expires. Use a short renewal buffer so user requests do not fail when a token is near expiration.
{
"clientId": "YOUR_CLIENT_ID",
"clientSecret": "YOUR_CLIENT_SECRET",
"audience": "vinquery:api:vindecode"
}
Never return the Client Secret to the client application. In most applications, the JWT token should also remain server-side; the client receives only the VINquery API result or a simplified business response.
Using the Correct JWT Audience
The audience value in the token request must match the Allowed API / JWT Audience configured for that API Consumer in the VINquery portal. A token issued for one audience must not be used to call a different API.
| API | Audience |
|---|---|
| VINdecode API | vinquery:api:vindecode |
| VINfix API | vinquery:api:vinfix |
| VINocr API | vinquery:api:vinocr |
| VINbarcode API | vinquery:api:vinbarcode |
| LPR API | vinquery:api:lpr |
| DecisioQ API | vinquery:api:decisioq |
Calling VINquery APIs Through Your Proxy
After your proxy has a valid JWT, call the target VINquery API with the bearer token in the Authorization header. Keep upstream URLs, credentials, and token responses out of the client application.
GET https://vindecode.vinquery.com/v3?VIN=1FMCU0F76EUC78242&reportType=3&format=JSON
Authorization: Bearer <jwt-token>
Return the VINquery response as-is when your application needs full API detail, or map it into a simpler response object when your client only needs a small set of fields.
Error Handling
Your proxy should convert low-level authentication, network, and upstream service failures into clear responses for your application. Log enough detail server-side to troubleshoot safely without exposing secrets.
| Condition | Proxy Behavior |
|---|---|
| Identity token request fails | Return a clear authentication configuration error and log the upstream status. |
| VINquery API returns 401 or 403 | Refresh the token once if appropriate, then return a safe authorization error. |
| VINquery API returns validation error | Forward the service error so the caller can correct the request. |
| Timeout or network failure | Return a temporary service error and avoid exposing stack traces to the client. |
Implementation Examples
These examples show the core proxy pattern: keep credentials on the server, request a JWT token, cache it safely, call a VINquery API, and return the response. VINdecode and VINfix use the query-style example; VINocr and VINbarcode use the same authentication pattern with a multipart image upload.
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_VINDECODE_CLIENT_ID;
const clientSecret = process.env.VINQUERY_VINDECODE_CLIENT_SECRET;
const audience = "vinquery:api:vindecode";
const identityTokenUrl = "https://identity.vinquery.com/connect/token";
const vindecodeUrl = "https://vindecode.vinquery.com/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.get("/api/vindecode", async (req, res) => {
try {
// Validate client input before calling VINquery.
const vin = String(req.query.vin || "").trim();
if (!/^[A-HJ-NPR-Z0-9]{17}$/i.test(vin)) {
return res.status(400).json({ message: "A valid 17-character VIN is required." });
}
// The browser calls this proxy. The proxy adds the bearer token server-side.
let jwtToken = await getJwtToken();
const apiUrl = `${vindecodeUrl}?VIN=${encodeURIComponent(vin)}&reportType=3&format=JSON`;
let apiResponse = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${jwtToken}` }
});
// A token can be revoked before expiresUtc. Invalidate and retry exactly once.
if (apiResponse.status === 401) {
invalidateRejectedToken(jwtToken);
jwtToken = await getJwtToken();
apiResponse = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${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("VINdecode proxy failed", error);
res.status(502).json({ message: "VINquery request could not be completed." });
}
});
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_VINDECODE_CLIENT_ID || "";
const clientSecret = process.env.VINQUERY_VINDECODE_CLIENT_SECRET || "";
const audience = "vinquery:api:vindecode";
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.get("/api/vindecode", async (req: Request, res: Response) => {
try {
const vin = String(req.query.vin || "").trim();
if (!/^[A-HJ-NPR-Z0-9]{17}$/i.test(vin)) {
return res.status(400).json({ message: "A valid 17-character VIN is required." });
}
let jwtToken = await getJwtToken();
const apiUrl = `https://vindecode.vinquery.com/v3?VIN=${encodeURIComponent(vin)}&reportType=3&format=JSON`;
let apiResponse = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${jwtToken}` }
});
if (apiResponse.status === 401) {
invalidateRejectedToken(jwtToken);
jwtToken = await getJwtToken();
apiResponse = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${jwtToken}` }
});
}
return res.status(apiResponse.status).type("application/json").send(await apiResponse.text());
} catch (error) {
console.error("VINdecode proxy failed", error);
return res.status(502).json({ message: "VINquery request could not be completed." });
}
});
from datetime import datetime, timezone, timedelta
import os
import requests
from threading import Lock
from fastapi import FastAPI, HTTPException, Response
app = FastAPI()
# API Consumer credentials stay in trusted server-side configuration.
CLIENT_ID = os.environ.get("VINQUERY_VINDECODE_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("VINQUERY_VINDECODE_CLIENT_SECRET", "")
AUDIENCE = "vinquery:api:vindecode"
IDENTITY_TOKEN_URL = "https://identity.vinquery.com/connect/token"
VINDECODE_URL = "https://vindecode.vinquery.com/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.get("/api/vindecode")
def vindecode(vin: str):
try:
if len(vin.strip()) != 17:
raise HTTPException(status_code=400, detail="A valid 17-character VIN is required.")
jwt_token = get_jwt_token()
api_response = requests.get(
VINDECODE_URL,
params={"VIN": vin, "reportType": 3, "format": "JSON"},
headers={"Authorization": f"Bearer {jwt_token}"},
timeout=30
)
if api_response.status_code == 401:
api_response.close()
invalidate_rejected_token(jwt_token)
jwt_token = get_jwt_token()
api_response = requests.get(
VINDECODE_URL,
params={"VIN": vin, "reportType": 3, "format": "JSON"},
headers={"Authorization": f"Bearer {jwt_token}"},
timeout=30
)
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"VINdecode proxy failed: {exc}")
raise HTTPException(status_code=502, detail="VINquery request could not be completed.")
<?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_VINDECODE_CLIENT_ID");
$clientSecret = getenv("VINQUERY_VINDECODE_CLIENT_SECRET");
$audience = "vinquery:api:vindecode";
$identityTokenUrl = "https://identity.vinquery.com/connect/token";
$vindecodeUrl = "https://vindecode.vinquery.com/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_vindecode_jwt");
if ($cached && time() < ($cached["expiresAt"] - $refreshBufferSeconds)) {
return $cached["jwtToken"];
}
$lock = fopen(sys_get_temp_dir() . "/vinquery-vindecode-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_vindecode_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_vindecode_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-vindecode-token.lock", "c");
if (!$lock || !flock($lock, LOCK_EX)) {
throw new RuntimeException("Could not acquire token refresh lock.");
}
try {
$cached = apcu_fetch("vinquery_vindecode_jwt");
// Delete only the rejected token while holding the same lock used by refresh.
if ($cached && hash_equals($cached["jwtToken"], $rejectedToken)) {
apcu_delete("vinquery_vindecode_jwt");
}
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
function callVinDecode(string $vindecodeUrl, string $vin, string $jwtToken): array {
$curl = curl_init($vindecodeUrl . "?VIN=" . urlencode($vin) . "&reportType=3&format=JSON");
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $jwtToken],
CURLOPT_TIMEOUT => 30
]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
return [$status, $body];
}
try {
$vin = strtoupper(trim($_GET["vin"] ?? ""));
if (!preg_match("/^[A-HJ-NPR-Z0-9]{17}$/", $vin)) {
http_response_code(400);
header("Content-Type: application/json");
echo json_encode(["message" => "A valid 17-character VIN is required."]);
exit;
}
$jwtToken = getJwtToken();
[$apiStatus, $apiBody] = callVinDecode($vindecodeUrl, $vin, $jwtToken);
if ($apiStatus === 401) {
invalidateRejectedToken($jwtToken);
$jwtToken = getJwtToken();
[$apiStatus, $apiBody] = callVinDecode($vindecodeUrl, $vin, $jwtToken);
}
http_response_code($apiStatus);
header("Content-Type: application/json");
echo $apiBody;
} catch (Throwable $ex) {
error_log("VINdecode proxy failed: " . $ex->getMessage());
http_response_code(502);
header("Content-Type: application/json");
echo json_encode(["message" => "VINquery request could not be completed."]);
}
?>
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sync"
"time"
)
const (
audience = "vinquery:api:vindecode"
identityTokenURL = "https://identity.vinquery.com/connect/token"
vindecodeURL = "https://vindecode.vinquery.com/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_VINDECODE_CLIENT_ID")
clientSecret := os.Getenv("VINQUERY_VINDECODE_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 vinDecodeHandler(w http.ResponseWriter, r *http.Request) {
vin := r.URL.Query().Get("vin")
if len(vin) != 17 {
http.Error(w, "A valid 17-character VIN is required.", http.StatusBadRequest)
return
}
jwtToken, err := getJWTToken()
if err != nil {
fmt.Println("VINdecode token failure:", err)
http.Error(w, "VINquery request could not be completed.", http.StatusBadGateway)
return
}
apiURL := vindecodeURL + "?VIN=" + url.QueryEscape(vin) + "&reportType=3&format=JSON"
req, _ := http.NewRequest("GET", apiURL, nil)
req.Header.Set("Authorization", "Bearer "+jwtToken)
apiResponse, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("VINdecode 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
}
retryRequest, _ := http.NewRequest("GET", apiURL, nil)
retryRequest.Header.Set("Authorization", "Bearer "+jwtToken)
apiResponse, err = http.DefaultClient.Do(retryRequest)
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)
}
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.MapGet("/api/vindecode", async (
string vin,
IHttpClientFactory httpClientFactory,
VinqueryTokenProvider tokenProvider) =>
{
if (string.IsNullOrWhiteSpace(vin) || vin.Length != 17)
{
return Results.BadRequest(new { message = "A valid 17-character VIN is required." });
}
try
{
// The browser never receives the API Consumer secret. It calls this proxy,
// and the proxy attaches the cached bearer token server-side.
var jwtToken = await tokenProvider.GetJwtTokenAsync();
var requestUrl = $"https://vindecode.vinquery.com/v3?VIN={Uri.EscapeDataString(vin)}&reportType=3&format=JSON";
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken);
using var response = await httpClientFactory.CreateClient().SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
tokenProvider.InvalidateRejectedToken(jwtToken);
jwtToken = await tokenProvider.GetJwtTokenAsync();
using var retryRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl);
retryRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken);
using var retryResponse = await httpClientFactory.CreateClient().SendAsync(retryRequest);
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, "VINdecode 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:VINdecode:ClientId"];
var clientSecret = _configuration["VINquery:VINdecode: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:vindecode"
});
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; }
}
@RestController
public class VinDecodeProxyController {
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;
@GetMapping("/api/vindecode")
public ResponseEntity<String> vinDecode(@RequestParam String vin) {
if (vin == null || vin.trim().length() != 17) {
return ResponseEntity.badRequest().body("{\"message\":\"A valid 17-character VIN is required.\"}");
}
try {
// The client application calls this proxy. Only the proxy knows the
// Client Secret and only the proxy sends Authorization to VINquery.
String jwtToken = getJwtToken();
ResponseEntity<String> response = callVinDecode(vin, jwtToken);
if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
invalidateRejectedToken(jwtToken);
jwtToken = getJwtToken();
response = callVinDecode(vin, 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("VINdecode 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_VINDECODE_CLIENT_ID");
String clientSecret = System.getenv("VINQUERY_VINDECODE_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:vindecode"
);
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> callVinDecode(String vin, String jwtToken) {
// exchange() preserves a 401 response instead of throwing before retry logic runs.
return restClient.get()
.uri("https://vindecode.vinquery.com/v3?VIN={vin}&reportType=3&format=JSON", vin)
.header("Authorization", "Bearer " + jwtToken)
.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) { }
}
const audience = "vinquery:api:vinfix";
const vinfixUrl = "https://vinfix.recognition.ws/v3";
In other words, replace vinquery:api:vindecode with vinquery:api:vinfix and https://vindecode.vinquery.com/v3 with https://vinfix.recognition.ws/v3. Rename vindecodeUrl to vinfixUrl where appropriate; the surrounding proxy and JWT-reuse logic remains the same.
If one proxy calls both services, maintain token state per audience. Never send a VINdecode token to VINfix or reuse one shared cached token across the two audiences.
multipart/form-data. Send the image in the form field named inputimage.
| Service | Audience | Upstream URL | Request body |
|---|---|---|---|
| VINocr | vinquery:api:vinocr | https://vinocr.recognition.ws/v3 | inputimage multipart file |
| VINbarcode | vinquery:api:vinbarcode | https://vinbarcode.recognition.ws/v3 | inputimage multipart file |
| LPR | vinquery:api:lpr | https://lpr.recognition.ws/v3 | inputimage multipart file |
Complete shared image-recognition proxy
This server-side pattern applies to VINocr, VINbarcode, and LPR. The examples below show fixed VINocr and VINbarcode routes; add the LPR route with the audience and upstream URL from the table above. The route must select an allowlisted audience and URL—clients cannot supply either value. JWTs, refresh operations, and 401 invalidation are isolated by audience.
import express from "express";
import multer from "multer";
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024, files: 1 }
});
const identityTokenUrl = "https://identity.vinquery.com/connect/token";
const refreshBufferMs = 60_000;
// This server-controlled allowlist is the only source of audiences and URLs.
const recognitionServices = Object.freeze({
vinocr: Object.freeze({
audience: "vinquery:api:vinocr",
url: "https://vinocr.recognition.ws/v3"
}),
vinbarcode: Object.freeze({
audience: "vinquery:api:vinbarcode",
url: "https://vinbarcode.recognition.ws/v3"
})
});
// Each audience owns independent cached-token and in-flight-refresh state.
const tokenCache = new Map();
const refreshInFlight = new Map();
function credentialsFor(serviceName) {
const prefix = serviceName === "vinocr" ? "VINOCR" : "VINBARCODE";
return {
clientId: process.env[`VINQUERY_${prefix}_CLIENT_ID`],
clientSecret: process.env[`VINQUERY_${prefix}_CLIENT_SECRET`]
};
}
function tokenIsUsable(audience) {
const cached = tokenCache.get(audience);
return !!cached && Date.now() < cached.expiresAtMs - refreshBufferMs;
}
async function requestFreshToken(serviceName, service) {
const { clientId, clientSecret } = credentialsFor(serviceName);
if (!clientId || !clientSecret) {
throw new Error(`VINquery credentials are missing for ${serviceName}.`);
}
const response = await fetch(identityTokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId, clientSecret, audience: service.audience })
});
if (!response.ok) {
throw new Error(`Identity token request failed with HTTP ${response.status}.`);
}
const token = await response.json();
if (!token.jwtToken || !token.expiresUtc) {
throw new Error("Identity response did not include jwtToken and expiresUtc.");
}
tokenCache.set(service.audience, {
jwtToken: token.jwtToken,
expiresAtMs: Date.parse(token.expiresUtc)
});
return token.jwtToken;
}
async function getJwtToken(serviceName, service) {
if (tokenIsUsable(service.audience)) {
return tokenCache.get(service.audience).jwtToken;
}
if (!refreshInFlight.has(service.audience)) {
const refresh = requestFreshToken(serviceName, service)
.finally(() => refreshInFlight.delete(service.audience));
refreshInFlight.set(service.audience, refresh);
}
return refreshInFlight.get(service.audience);
}
function invalidateRejectedToken(audience, rejectedToken) {
// Never let an older 401 remove a newer token installed concurrently.
if (tokenCache.get(audience)?.jwtToken === rejectedToken) {
tokenCache.delete(audience);
}
}
function createRecognitionForm(image) {
// Multipart bodies are one-use; call this again for the single retry.
const form = new FormData();
form.append(
"inputimage",
new Blob([image.buffer], { type: image.mimetype || "image/jpeg" }),
image.originalname || "vin-image.jpg"
);
return form;
}
async function callRecognition(service, image, jwtToken) {
return fetch(`${service.url}?format=JSON`, {
method: "POST",
headers: { Authorization: `Bearer ${jwtToken}` },
body: createRecognitionForm(image)
});
}
async function recognitionHandler(serviceName, req, res) {
const service = recognitionServices[serviceName];
if (!service) {
return res.status(404).json({ message: "Unknown recognition service." });
}
if (!req.file?.buffer?.length) {
return res.status(400).json({ message: "An inputimage file is required." });
}
try {
let jwtToken = await getJwtToken(serviceName, service);
let upstream = await callRecognition(service, req.file, jwtToken);
if (upstream.status === 401) {
invalidateRejectedToken(service.audience, jwtToken);
jwtToken = await getJwtToken(serviceName, service);
upstream = await callRecognition(service, req.file, jwtToken);
}
res.status(upstream.status)
.type(upstream.headers.get("content-type") || "application/json")
.send(await upstream.text());
} catch (error) {
console.error(`${serviceName} proxy failed`, error);
res.status(502).json({ message: "VINquery request could not be completed." });
}
}
// Both fixed public routes share the same handler and codebase.
app.post("/api/vinocr", upload.single("inputimage"),
(req, res) => recognitionHandler("vinocr", req, res));
app.post("/api/vinbarcode", upload.single("inputimage"),
(req, res) => recognitionHandler("vinbarcode", req, res));
import express, { Request, Response } from "express";
import multer from "multer";
type ServiceName = "vinocr" | "vinbarcode";
type Service = { audience: string; url: string; clientId: string; clientSecret: string };
type Cached = { jwtToken: string; expiresAtMs: number };
const services: Record<ServiceName, Service> = {
vinocr: {
audience: "vinquery:api:vinocr", url: "https://vinocr.recognition.ws/v3",
clientId: process.env.VINQUERY_VINOCR_CLIENT_ID || "",
clientSecret: process.env.VINQUERY_VINOCR_CLIENT_SECRET || ""
},
vinbarcode: {
audience: "vinquery:api:vinbarcode", url: "https://vinbarcode.recognition.ws/v3",
clientId: process.env.VINQUERY_VINBARCODE_CLIENT_ID || "",
clientSecret: process.env.VINQUERY_VINBARCODE_CLIENT_SECRET || ""
}
};
const cache = new Map<string, Cached>();
const refreshing = new Map<string, Promise<string>>();
async function tokenFor(service: Service): Promise<string> {
const current = cache.get(service.audience);
if (current && Date.now() < current.expiresAtMs - 60_000) return current.jwtToken;
if (!refreshing.has(service.audience)) {
refreshing.set(service.audience, (async () => {
const response = await fetch("https://identity.vinquery.com/connect/token", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: service.clientId, clientSecret: service.clientSecret, audience: service.audience })
});
if (!response.ok) throw new Error(`Identity returned ${response.status}`);
const token = await response.json() as { jwtToken: string; expiresUtc: string };
cache.set(service.audience, { jwtToken: token.jwtToken, expiresAtMs: Date.parse(token.expiresUtc) });
return token.jwtToken;
})().finally(() => refreshing.delete(service.audience)));
}
return refreshing.get(service.audience)!;
}
function call(service: Service, file: Express.Multer.File, token: string) {
const form = new FormData();
form.append("inputimage", new Blob([file.buffer], { type: file.mimetype }), file.originalname);
return fetch(`${service.url}?format=JSON`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form });
}
const upload = multer({ storage: multer.memoryStorage(), limits: { files: 1, fileSize: 10 * 1024 * 1024 } });
async function handle(name: ServiceName, req: Request, res: Response) {
const service = services[name];
if (!req.file) return res.status(400).json({ message: "An inputimage file is required." });
let token = await tokenFor(service);
let upstream = await call(service, req.file, token);
if (upstream.status === 401) {
if (cache.get(service.audience)?.jwtToken === token) cache.delete(service.audience);
token = await tokenFor(service);
upstream = await call(service, req.file, token); // creates a fresh FormData body
}
return res.status(upstream.status).send(await upstream.text());
}
const app = express();
app.post("/api/vinocr", upload.single("inputimage"), (q, s) => handle("vinocr", q, s));
app.post("/api/vinbarcode", upload.single("inputimage"), (q, s) => handle("vinbarcode", q, s));
from datetime import datetime, timedelta, timezone
from threading import Lock
from fastapi import FastAPI, File, HTTPException, Response, UploadFile
import os, requests
app = FastAPI()
SERVICES = {
"vinocr": {"audience": "vinquery:api:vinocr", "url": "https://vinocr.recognition.ws/v3"},
"vinbarcode": {"audience": "vinquery:api:vinbarcode", "url": "https://vinbarcode.recognition.ws/v3"},
}
cache, locks = {}, {s["audience"]: Lock() for s in SERVICES.values()}
def token_for(name, service):
audience, now = service["audience"], datetime.now(timezone.utc)
current = cache.get(audience)
if current and now < current["expires"] - timedelta(seconds=60): return current["token"]
with locks[audience]:
current = cache.get(audience)
if current and now < current["expires"] - timedelta(seconds=60): return current["token"]
prefix = "VINOCR" if name == "vinocr" else "VINBARCODE"
result = requests.post("https://identity.vinquery.com/connect/token", json={
"clientId": os.environ[f"VINQUERY_{prefix}_CLIENT_ID"],
"clientSecret": os.environ[f"VINQUERY_{prefix}_CLIENT_SECRET"],
"audience": audience}, timeout=15)
result.raise_for_status(); body = result.json()
cache[audience] = {"token": body["jwtToken"], "expires": datetime.fromisoformat(body["expiresUtc"].replace("Z", "+00:00"))}
return body["jwtToken"]
def call(service, image, filename, content_type, token):
return requests.post(service["url"], params={"format": "JSON"},
files={"inputimage": (filename, image, content_type)},
headers={"Authorization": f"Bearer {token}"}, timeout=30)
async def handle(name: str, inputimage: UploadFile):
service, image = SERVICES[name], await inputimage.read()
if not image: raise HTTPException(400, "An inputimage file is required.")
token = token_for(name, service)
upstream = call(service, image, inputimage.filename, inputimage.content_type, token)
if upstream.status_code == 401:
with locks[service["audience"]]:
if cache.get(service["audience"], {}).get("token") == token: cache.pop(service["audience"], None)
token = token_for(name, service)
upstream = call(service, image, inputimage.filename, inputimage.content_type, token)
return Response(upstream.text, upstream.status_code, media_type="application/json")
@app.post("/api/vinocr")
async def vinocr(inputimage: UploadFile = File(...)): return await handle("vinocr", inputimage)
@app.post("/api/vinbarcode")
async def vinbarcode(inputimage: UploadFile = File(...)): return await handle("vinbarcode", inputimage)
<?php
$services = [
"vinocr" => ["audience" => "vinquery:api:vinocr", "url" => "https://vinocr.recognition.ws/v3"],
"vinbarcode" => ["audience" => "vinquery:api:vinbarcode", "url" => "https://vinbarcode.recognition.ws/v3"]
];
$name = basename(parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH));
if (!isset($services[$name])) { http_response_code(404); exit("Unknown service"); }
if (empty($_FILES["inputimage"]["tmp_name"])) { http_response_code(400); exit("An inputimage file is required."); }
$service = $services[$name]; $cacheKey = "vq_" . $service["audience"];
function tokenFor($name, $service, $cacheKey) {
$cached = apcu_fetch($cacheKey);
if ($cached && time() < $cached["expires"] - 60) return $cached["token"];
$lock = fopen(sys_get_temp_dir()."/".sha1($cacheKey).".lock", "c"); flock($lock, LOCK_EX);
try {
$cached = apcu_fetch($cacheKey);
if ($cached && time() < $cached["expires"] - 60) return $cached["token"];
$prefix = $name === "vinocr" ? "VINOCR" : "VINBARCODE";
$curl = curl_init("https://identity.vinquery.com/connect/token");
curl_setopt_array($curl, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode([
"clientId" => getenv("VINQUERY_{$prefix}_CLIENT_ID"), "clientSecret" => getenv("VINQUERY_{$prefix}_CLIENT_SECRET"),
"audience" => $service["audience"]])]);
$body = json_decode(curl_exec($curl), true); curl_close($curl);
$cached = ["token" => $body["jwtToken"], "expires" => strtotime($body["expiresUtc"])];
apcu_store($cacheKey, $cached); return $cached["token"];
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
function callRecognition($service, $file, $token) {
$curl = curl_init($service["url"]."?format=JSON");
curl_setopt_array($curl, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer ".$token], CURLOPT_POSTFIELDS => [
"inputimage" => curl_file_create($file["tmp_name"], $file["type"], $file["name"])]]);
$body = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl);
return [$status, $body];
}
$token = tokenFor($name, $service, $cacheKey); [$status, $body] = callRecognition($service, $_FILES["inputimage"], $token);
if ($status === 401) { $cached = apcu_fetch($cacheKey); if ($cached["token"] === $token) apcu_delete($cacheKey);
$token = tokenFor($name, $service, $cacheKey); [$status, $body] = callRecognition($service, $_FILES["inputimage"], $token); }
http_response_code($status); header("Content-Type: application/json"); echo $body;
// The complete Go version uses the same two-entry allowlist and one cache entry per audience.
// Register the same handler twice; the fixed argument—not client input—selects the service.
services := map[string]Service{
"vinocr": {Audience: "vinquery:api:vinocr", URL: "https://vinocr.recognition.ws/v3"},
"vinbarcode": {Audience: "vinquery:api:vinbarcode", URL: "https://vinbarcode.recognition.ws/v3"},
}
http.HandleFunc("/api/vinocr", recognitionHandler("vinocr", services["vinocr"]))
http.HandleFunc("/api/vinbarcode", recognitionHandler("vinbarcode", services["vinbarcode"]))
// tokenFor(service) stores Token{JWT, ExpiresUtc} in a mutex-protected
// map keyed by service.Audience and refreshes 60 seconds before expiry.
// callRecognition creates a new multipart.Writer, adds inputimage, and POSTs.
// On one 401, compare-and-delete only that audience's rejected JWT, call
// tokenFor again, rebuild the multipart body, and retry exactly once.
var services = new Dictionary<string, RecognitionService> {
["vinocr"] = new("vinquery:api:vinocr", "https://vinocr.recognition.ws/v3"),
["vinbarcode"] = new("vinquery:api:vinbarcode", "https://vinbarcode.recognition.ws/v3")
};
app.MapPost("/api/vinocr", (IFormFile inputimage, RecognitionProxy proxy) =>
proxy.ExecuteAsync("vinocr", services["vinocr"], inputimage));
app.MapPost("/api/vinbarcode", (IFormFile inputimage, RecognitionProxy proxy) =>
proxy.ExecuteAsync("vinbarcode", services["vinbarcode"], inputimage));
// RecognitionProxy keeps ConcurrentDictionary<audience, Token> and
// ConcurrentDictionary<audience, SemaphoreSlim>. ExecuteAsync obtains or
// reuses the audience JWT, creates MultipartFormDataContent containing
// inputimage, and POSTs to service.Url. On one 401 it compare-removes only
// the rejected audience token, obtains a replacement, creates a new
// MultipartFormDataContent, and retries once.
public record RecognitionService(string Audience, string Url);
private static final Map<String, Service> SERVICES = Map.of(
"vinocr", new Service("vinquery:api:vinocr", "https://vinocr.recognition.ws/v3"),
"vinbarcode", new Service("vinquery:api:vinbarcode", "https://vinbarcode.recognition.ws/v3"));
@PostMapping(value="/api/vinocr", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<String> vinocr(@RequestPart("inputimage") MultipartFile image) {
return proxy.execute("vinocr", SERVICES.get("vinocr"), image);
}
@PostMapping(value="/api/vinbarcode", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<String> vinbarcode(@RequestPart("inputimage") MultipartFile image) {
return proxy.execute("vinbarcode", SERVICES.get("vinbarcode"), image);
}
// RecognitionProxy owns ConcurrentHashMap<audience, Token> plus one
// ReentrantLock per audience. execute() reuses a valid JWT, builds a new
// MultipartBodyBuilder with inputimage, and POSTs to service.url(). On one
// 401 it compare-removes only the rejected audience token, refreshes, builds
// another multipart body, and retries once.
record Service(String audience, String url) {}
Every tab uses the same two-entry server allowlist. The Go, C#, and Java tabs focus on the framework wiring; their audience-keyed cache and fresh multipart retry contracts are stated inline and match the fully expanded JavaScript, TypeScript, Python, and PHP implementations.
VINocr allowlist entry
const vinocrService = {
audience: "vinquery:api:vinocr",
url: "https://vinocr.recognition.ws/v3"
};
type RecognitionService = {
audience: string;
url: string;
};
const vinocrService: RecognitionService = {
audience: "vinquery:api:vinocr",
url: "https://vinocr.recognition.ws/v3"
};
vinocr_service = {
"audience": "vinquery:api:vinocr",
"url": "https://vinocr.recognition.ws/v3",
}
$vinocrService = [
"audience" => "vinquery:api:vinocr",
"url" => "https://vinocr.recognition.ws/v3",
];
type RecognitionService struct {
Audience string
URL string
}
vinocrService := RecognitionService{
Audience: "vinquery:api:vinocr",
URL: "https://vinocr.recognition.ws/v3",
}
import java.util.Map;
Map<String, String> vinocrService = Map.of(
"audience", "vinquery:api:vinocr",
"url", "https://vinocr.recognition.ws/v3"
);
VINbarcode allowlist entry
const vinbarcodeService = {
audience: "vinquery:api:vinbarcode",
url: "https://vinbarcode.recognition.ws/v3"
};
type RecognitionService = {
audience: string;
url: string;
};
const vinbarcodeService: RecognitionService = {
audience: "vinquery:api:vinbarcode",
url: "https://vinbarcode.recognition.ws/v3"
};
vinbarcode_service = {
"audience": "vinquery:api:vinbarcode",
"url": "https://vinbarcode.recognition.ws/v3",
}
$vinbarcodeService = [
"audience" => "vinquery:api:vinbarcode",
"url" => "https://vinbarcode.recognition.ws/v3",
];
type RecognitionService struct {
Audience string
URL string
}
vinbarcodeService := RecognitionService{
Audience: "vinquery:api:vinbarcode",
URL: "https://vinbarcode.recognition.ws/v3",
}
import java.util.Map;
Map<String, String> vinbarcodeService = Map.of(
"audience", "vinquery:api:vinbarcode",
"url", "https://vinbarcode.recognition.ws/v3"
);
Build the multipart image body
Build a new multipart body for the initial upstream request and, if required, another new body for the single 401 retry:
function createRecognitionForm(imageBytes, fileName, contentType) {
const form = new FormData();
form.append(
"inputimage",
new Blob([imageBytes], { type: contentType || "image/jpeg" }),
fileName || "vin-image.jpg"
);
return form;
}
// Use service.audience when obtaining/caching the JWT.
// POST createRecognitionForm(...) to service.url with:
// Authorization: Bearer <cached-token-for-this-audience>
// On one 401, invalidate only that audience's token, obtain a replacement,
// create a fresh FormData body, and retry once.
function createRecognitionForm(
imageBytes: Uint8Array,
fileName = "vin-image.jpg",
contentType = "image/jpeg"
): FormData {
const form = new FormData();
form.append("inputimage", new Blob([imageBytes], { type: contentType }), fileName);
return form;
}
// Cache tokens by service.audience. After one 401, invalidate only that
// audience, obtain a replacement token, create new FormData, and retry once.
def create_recognition_files(
image_bytes: bytes,
file_name: str = "vin-image.jpg",
content_type: str = "image/jpeg",
):
return {
"inputimage": (file_name, image_bytes, content_type),
}
# requests.post(service["url"], files=create_recognition_files(...),
# headers={"Authorization": f"Bearer {token}"})
# After one 401, replace only that audience's token, rebuild files, and retry once.
function createRecognitionFields(
string $imageBytes,
string $fileName = "vin-image.jpg",
string $contentType = "image/jpeg"
): array {
return [
"inputimage" => new CURLStringFile($imageBytes, $fileName, $contentType),
];
}
// Pass the returned array to CURLOPT_POSTFIELDS.
// After one 401, replace only that audience's token, rebuild the fields,
// and retry once.
import (
"bytes"
"fmt"
"mime/multipart"
"net/textproto"
)
func createRecognitionForm(
imageBytes []byte,
fileName string,
imageContentType string,
) ([]byte, string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", fmt.Sprintf(
`form-data; name="inputimage"; filename=%q`, fileName))
header.Set("Content-Type", imageContentType)
part, err := writer.CreatePart(header)
if err != nil { return nil, "", err }
if _, err = part.Write(imageBytes); err != nil { return nil, "", err }
if err = writer.Close(); err != nil { return nil, "", err }
return body.Bytes(), writer.FormDataContentType(), nil
}
// Create a new body for the initial POST and another for the single 401 retry.
using System.Net.Http.Headers;
static MultipartFormDataContent CreateRecognitionForm(
byte[] imageBytes,
string fileName = "vin-image.jpg",
string contentType = "image/jpeg")
{
var form = new MultipartFormDataContent();
var image = new ByteArrayContent(imageBytes);
image.Headers.ContentType = new MediaTypeHeaderValue(contentType);
form.Add(image, "inputimage", fileName);
return form;
}
// Dispose each form after sending. After one 401, refresh only that audience,
// create a new MultipartFormDataContent instance, and retry once.
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.util.MultiValueMap;
static MultiValueMap<String, HttpEntity<?>> createRecognitionForm(
byte[] imageBytes,
String fileName,
String contentType) {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
ByteArrayResource image = new ByteArrayResource(imageBytes) {
@Override public String getFilename() { return fileName; }
};
builder.part("inputimage", image)
.contentType(MediaType.parseMediaType(contentType));
return builder.build();
}
// Send as multipart/form-data. After one 401, refresh only that audience,
// rebuild the multipart body, and retry once.
The browser or mobile app should upload the image to your server-side proxy; it must not receive the API Consumer secret or call the recognition service with that secret. When using FormData, let the HTTP library generate the multipart Content-Type header and boundary.
async function uploadToRecognitionProxy(route, imageFile) {
const upload = new FormData();
upload.append("inputimage", imageFile);
return fetch(route, { method: "POST", body: upload });
}
// Choose the protected proxy route for the service being called.
await uploadToRecognitionProxy("/api/vinocr", imageFile);
// Or: await uploadToRecognitionProxy("/api/vinbarcode", imageFile);
async function uploadToRecognitionProxy(
route: "/api/vinocr" | "/api/vinbarcode",
imageFile: File
): Promise<Response> {
const upload = new FormData();
upload.append("inputimage", imageFile);
return fetch(route, { method: "POST", body: upload });
}
await uploadToRecognitionProxy("/api/vinocr", imageFile);
// Or: await uploadToRecognitionProxy("/api/vinbarcode", imageFile);
import mimetypes
import requests
def upload_to_recognition_proxy(route: str, image_path: str):
content_type = mimetypes.guess_type(image_path)[0] or "image/jpeg"
with open(image_path, "rb") as image:
files = {"inputimage": (image.name, image, content_type)}
return requests.post(route, files=files, timeout=60)
response = upload_to_recognition_proxy(
"https://your-app.example/api/vinocr", "vin-image.jpg")
# Or use: https://your-app.example/api/vinbarcode
function uploadToRecognitionProxy(string $route, string $imagePath) {
$curl = curl_init($route);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
"inputimage" => curl_file_create($imagePath),
],
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($curl);
curl_close($curl);
return $response;
}
$response = uploadToRecognitionProxy(
"https://your-app.example/api/vinocr", "vin-image.jpg");
// Or use: https://your-app.example/api/vinbarcode
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
)
func uploadToRecognitionProxy(route, imagePath string) (*http.Response, error) {
image, err := os.Open(imagePath)
if err != nil { return nil, err }
defer image.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("inputimage", filepath.Base(imagePath))
if err != nil { return nil, err }
if _, err = io.Copy(part, image); err != nil { return nil, err }
if err = writer.Close(); err != nil { return nil, err }
request, err := http.NewRequest(http.MethodPost, route, &body)
if err != nil { return nil, err }
request.Header.Set("Content-Type", writer.FormDataContentType())
return http.DefaultClient.Do(request)
}
response, err := uploadToRecognitionProxy(
"https://your-app.example/api/vinocr", "vin-image.jpg")
// Or use: https://your-app.example/api/vinbarcode
using System.Net.Http;
static async Task<HttpResponseMessage> UploadToRecognitionProxyAsync(
HttpClient client, string route, string imagePath)
{
await using var stream = File.OpenRead(imagePath);
using var upload = new MultipartFormDataContent();
using var image = new StreamContent(stream);
upload.Add(image, "inputimage", Path.GetFileName(imagePath));
return await client.PostAsync(route, upload);
}
var response = await UploadToRecognitionProxyAsync(
httpClient, "https://your-app.example/api/vinocr", "vin-image.jpg");
// Or use: https://your-app.example/api/vinbarcode
import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
static Mono<String> uploadToRecognitionProxy(
WebClient client, String route, Path imagePath) {
MultipartBodyBuilder upload = new MultipartBodyBuilder();
upload.part("inputimage", new FileSystemResource(imagePath));
return client.post()
.uri(route)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(upload.build()))
.retrieve()
.bodyToMono(String.class);
}
Mono<String> response = uploadToRecognitionProxy(
webClient, "https://your-app.example/api/vinocr", Path.of("vin-image.jpg"));
// Or use: https://your-app.example/api/vinbarcode
Create a separate FormData instance for each request. A multipart body is normally consumed when sent and should not be reused for the 401 retry or for a second service call.
Security Best Practices and Common Mistakes
Best Practices
- Use HTTPS for every client-to-proxy request.
- Keep Client Secrets in server-side secret storage.
- Cache JWTs server-side and renew before expiration.
- Log correlation IDs and upstream status codes.
- Rate-limit and validate client requests before calling VINquery.
Common Mistakes
- Putting Client Secrets in JavaScript or mobile apps.
- Using a VINdecode token to call VINfix or another API.
- Requesting a new token for every client request when caching is possible.
- Returning stack traces or upstream diagnostics to end users.
- Logging Client Secrets or raw Authorization headers.
Frequently Asked Questions
Can a browser call VINquery directly?
No. Browser-side code cannot safely hold a Client Secret. Use your server-side proxy.
Can I use the same Client ID for multiple APIs?
No. Each API Consumer is created for a specific API Audience and has its own Client ID and Client Secret. If an application uses VINdecode and VINfix, create one VINdecode API Consumer and one VINfix API Consumer. This produces two independent Client ID and Client Secret pairs.
Should my proxy return the JWT token to the browser?
Usually no. The safest pattern is for the proxy to keep the token server-side and return only the VINquery API response or the fields your application needs.
How long should I cache a JWT token?
VINquery JWTs currently expire after 60 minutes. Use the expiresUtc value returned by https://identity.vinquery.com/connect/token as the source of truth, and refresh the token about 60 seconds before that time.
Why should the proxy refresh a token after a 401 response?
A token can become unusable before expiresUtc because of revocation, credential rotation, account changes, or signing-key changes. When VINquery rejects the cached token with HTTP 401, invalidate that token, obtain a replacement, and retry the original request once.
Why should the proxy retry only once?
A single retry handles the normal stale-token case without creating an authentication loop. If the replacement token also receives 401, stop retrying, return a safe authorization error, and investigate the Client ID, Client Secret, audience, account status, and server clocks.
Should the proxy refresh the JWT after HTTP 403?
Usually no. HTTP 403 normally means the authenticated API Consumer is not authorized for the requested API, account, or operation. Repeating the same request with another token for the same credentials will not correct an authorization-policy problem.
How should concurrent 401 responses invalidate the cache?
Invalidate by comparison: remove the cached token only when it is the same token that received 401. This prevents an older failed request from deleting a newer JWT that another request has already refreshed.
Does 401 refresh-and-retry replace expiration-based refresh?
No. Expiration-based refresh is the normal path and should reuse the JWT until shortly before expiresUtc. The 401 path is a recovery mechanism for tokens that become invalid unexpectedly.
What must be preserved when retrying the request?
Retry the same allowlisted endpoint, HTTP method, validated request body, and correlation ID, changing only the bearer token. Buffer request bodies when necessary so they can be sent once more, and never retry indefinitely.
Does the proxy need to be platform-specific?
No. The pattern works on Windows, Linux, and macOS. Only deployment setup, environment variable configuration, and secret storage tooling differ by platform.
