VINquery developer documentation

Fix JWT token caching

Receiving a reminder about frequent token requests? Start by checking whether your application keeps its token between API calls. The usual fix is to move the token cache outside the code that runs for each request.

The change to make
  1. Request a JWT once and keep jwtToken and expiresUtc in your server application.
  2. Before the next API call, check the saved expiry. If the token is still usable, send it again as Authorization: Bearer <token>.
  3. Obtain a replacement shortly before expiry. Let simultaneous requests share that renewal.
  4. Keep one cache per client credential and audience, with a lifetime that spans multiple API calls.

For a 60-minute token, a one-minute renewal buffer means roughly 59 minutes of reuse. Always use the returned expiresUtc; the server's token lifetime can change.

1. Find where the cache is being lost

What you seeWhat to checkChange to make
One call to /connect/token for every VIN or image request.Does each request call the token endpoint directly?Call a cache helper first. The helper should contact Identity only when no usable token is cached.
A cache exists, but every incoming request starts with an empty token.Is the cache created inside a method, controller, or request handler?Create it once at application startup. In ASP.NET Core, register the token cache as a singleton; in Node.js, keep the provider at module scope.
Several new tokens appear together when the app starts or a token expires.Can concurrent requests all notice an empty cache and renew independently?Use a lock or shared promise, then check the cache again before renewing.
The token is renewed constantly, or used after it expires.Is expiresUtc parsed as UTC? Is the machine clock correct?Keep the returned absolute expiry and compare it to the current UTC time with a small buffer. Do not reset the expiry on each API call.
New tokens arrive around hourly from several servers.Does each process keep its own token?This may be expected. Evaluate each application instance separately before changing a working cache.

2. Change the request flow

Repeated token requests

For every API call:
  Request a new JWT from /connect/token
  Call the VINquery API using that JWT

Ten API calls produce ten new tokens.

A shared token cache

For every API call:
  If the saved token is still usable:
    Reuse it
  Otherwise, coordinate one renewal:
    Check the cache again
    Request and save a replacement if needed
  Call the VINquery API using the cached JWT

Ten calls within one token's usable lifetime need one token per application instance, credential, and audience.

Example: a token received at 10:00 expires at 11:00. Reuse it for calls at 10:01, 10:10, and 10:45. With a one-minute buffer, the next call at or after 10:59 obtains a replacement. A background renewal timer is unnecessary; the next API request can trigger renewal.

3A. C# / ASP.NET Core: register the cache once

Download VinqueryTokenCache.cs into your .NET 8+ server project. It caches the token and expiry, coordinates renewal with a semaphore, and checks the cache again after waiting.

Add this to startup, before builder.Build():

builder.Services.AddHttpClient("VinqueryIdentity", client =>
    client.Timeout = TimeSpan.FromSeconds(30));

builder.Services.AddSingleton(sp => new VinqueryTokenCache(
    sp.GetRequiredService<IHttpClientFactory>(),
    builder.Configuration["Vinquery:ClientId"] ?? throw new InvalidOperationException("Missing ClientId"),
    builder.Configuration["Vinquery:ClientSecret"] ?? throw new InvalidOperationException("Missing ClientSecret"),
    "vinquery:api:vindecode"));

Inject that same VinqueryTokenCache into your existing API client or handler:

var jwt = await tokens.GetTokenAsync(cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, vinqueryApiUrl);
request.Headers.Authorization =
    new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
using var response = await httpClient.SendAsync(request, cancellationToken);

Use AddSingleton for this cache, not AddScoped or AddTransient. Do not create a new VinqueryTokenCache inside each request. The registration above represents one credential/audience pair; give other pairs separate long-lived instances.

View the complete C# cache helper
// .NET 8+; server-side only. Register ONE instance per credential/audience
// as a singleton, so the cache survives individual API requests.
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;

public sealed class VinqueryTokenCache
{
    private readonly IHttpClientFactory _clients;
    private readonly string _clientId, _clientSecret, _audience;
    private readonly TimeProvider _clock;
    private readonly SemaphoreSlim _refreshLock = new(1, 1);
    private CachedToken? _cached;

    public VinqueryTokenCache(IHttpClientFactory clients, string clientId,
        string clientSecret, string audience, TimeProvider? clock = null)
    {
        if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)
            || string.IsNullOrWhiteSpace(audience))
            throw new ArgumentException("Client ID, client secret and audience are required.");
        _clients = clients;
        _clientId = clientId;
        _clientSecret = clientSecret;
        _audience = audience;
        _clock = clock ?? TimeProvider.System;
    }

    public async Task<string> GetTokenAsync(CancellationToken cancellationToken = default)
    {
        var cached = Volatile.Read(ref _cached);
        if (cached != null && _clock.GetUtcNow() < cached.RefreshAt) return cached.Token;

        await _refreshLock.WaitAsync(cancellationToken);
        try
        {
            // Another caller may have refreshed while this caller was waiting.
            cached = Volatile.Read(ref _cached);
            if (cached != null && _clock.GetUtcNow() < cached.RefreshAt) return cached.Token;

            using var client = _clients.CreateClient("VinqueryIdentity");
            using var response = await client.PostAsJsonAsync(
                "https://identity.vinquery.com/connect/token",
                new { clientId = _clientId, clientSecret = _clientSecret, audience = _audience },
                cancellationToken);
            response.EnsureSuccessStatusCode();
            var token = await response.Content.ReadFromJsonAsync<TokenResponse>(
                cancellationToken: cancellationToken);
            var now = _clock.GetUtcNow();
            if (token == null || string.IsNullOrWhiteSpace(token.JwtToken) || token.ExpiresUtc <= now)
                throw new InvalidOperationException("VINquery returned an empty token or invalid expiry.");

            var bufferSeconds = Math.Min(60, (token.ExpiresUtc - now).TotalSeconds * 0.1);
            Volatile.Write(ref _cached, new CachedToken(token.JwtToken,
                token.ExpiresUtc.AddSeconds(-bufferSeconds)));
            return token.JwtToken;
        }
        finally { _refreshLock.Release(); }
    }

    public void Invalidate(string rejectedToken)
    {
        var cached = Volatile.Read(ref _cached);
        if (cached?.Token == rejectedToken)
            Interlocked.CompareExchange(ref _cached, null, cached);
    }

    private sealed record CachedToken(string Token, DateTimeOffset RefreshAt);
    private sealed class TokenResponse
    {
        [JsonPropertyName("jwtToken")] public string JwtToken { get; set; } = "";
        [JsonPropertyName("expiresUtc")] public DateTimeOffset ExpiresUtc { get; set; }
    }
}

3B. Node.js: keep one provider outside the handler

Download vinquery-token-cache.mjs into your Node.js server project (use a supported Node.js 22+ release). The provider shares one renewal promise between concurrent callers.

import { createTokenProvider } from "./vinquery-token-cache.mjs";

// Run once when this module loads, outside all request handlers.
const tokens = createTokenProvider({
  clientId: process.env.VINQUERY_CLIENT_ID,
  clientSecret: process.env.VINQUERY_CLIENT_SECRET,
  audience: "vinquery:api:vindecode"
});

// Call this from your existing application code.
export async function decodeVin(vin) {
  const url = new URL("https://vindecode.vinquery.com/v3");
  url.search = new URLSearchParams({ VIN: vin, reportType: "3", format: "JSON" });
  const jwt = await tokens.getToken();
  return fetch(url, { headers: { Authorization: `Bearer ${jwt}` } });
}

Calling tokens.getToken() for each VIN is correct: most calls return the cached string. Calling createTokenProvider() for each VIN would create an empty cache each time.

View the complete Node.js cache helper
// Node.js 18+; server-side only. Create ONE provider per credential/audience
// at application startup, outside request handlers. Keep secrets in configuration.
export function createTokenProvider({ clientId, clientSecret, audience }) {
  if (!clientId || !clientSecret || !audience) {
    throw new Error("Client ID, client secret and audience are required.");
  }

  let cached = null;
  let refreshInFlight = null;

  async function requestToken() {
    const response = await fetch("https://identity.vinquery.com/connect/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ clientId, clientSecret, audience }),
      signal: AbortSignal.timeout(30_000)
    });
    if (!response.ok) {
      throw new Error(`VINquery token request failed (HTTP ${response.status}).`);
    }

    const result = await response.json();
    const expiresAt = Date.parse(result.expiresUtc);
    const now = Date.now();
    if (typeof result.jwtToken !== "string" || !result.jwtToken.trim()
        || !Number.isFinite(expiresAt) || expiresAt <= now) {
      throw new Error("VINquery returned an empty token or invalid expiry.");
    }

    // Normally renew one minute early; allow shorter token lifetimes too.
    const bufferMs = Math.min(60_000, (expiresAt - now) * 0.1);
    cached = { token: result.jwtToken, refreshAt: expiresAt - bufferMs };
    return cached.token;
  }

  async function getToken() {
    if (cached && Date.now() < cached.refreshAt) return cached.token;
    // All concurrent callers await the SAME token request.
    if (!refreshInFlight) {
      refreshInFlight = requestToken().finally(() => { refreshInFlight = null; });
    }
    return refreshInFlight;
  }

  function invalidate(rejectedToken) {
    // A late 401 for an old token must not remove a newer cached token.
    if (cached?.token === rejectedToken) cached = null;
  }

  return { getToken, invalidate };
}

3C. PHP: keep the cache between web requests

Download VinqueryTokenCache.php for PHP 8.2+ with the cURL extension. This example stores the token in a private local file and uses flock to coordinate PHP-FPM or CGI workers on one server. It needs no Redis service or APCu extension.

First create a persistent cache directory outside your website's document root. Give only the application OS account access (directory mode 0700 on Unix, or equivalent Windows permissions). Set VINQUERY_CACHE_DIR to its absolute path. Every request must use the same directory.

require_once __DIR__ . '/VinqueryTokenCache.php';

$tokens = new VinqueryTokenCache(
    getenv('VINQUERY_CLIENT_ID') ?: '',
    getenv('VINQUERY_CLIENT_SECRET') ?: '',
    'vinquery:api:vindecode',
    getenv('VINQUERY_CACHE_DIR') ?: ''
);

// Recreating this PHP object is OK: the FILE survives the request.
$jwt = $tokens->getToken();
$url = 'https://vindecode.vinquery.com/v3?' . http_build_query([
    'VIN' => $vin, 'reportType' => '3', 'format' => 'JSON'
]);
$curl = curl_init($url);
curl_setopt_array($curl, [
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $jwt],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30
]);
try {
    $body = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($body === false || $status < 200 || $status >= 300) {
        throw new RuntimeException('VINquery API request failed.');
    }
    // Process $body in your application.
} finally {
    unset($curl);
}

The filename separates each client credential and audience, including credential rotation. Do not delete the cache after each request or use a temporary directory that changes per request. A token cache is shared by the integration, not by individual website visitors.

Use this file example on a local filesystem with working file locks and separate PHP worker processes. For multiple hosts, network filesystems, or a threaded PHP server, use your framework's shared cache and renewal lock, such as Redis. Store both the token and its absolute expiry, and recheck the cache after acquiring the lock.

View the complete PHP cache helper
<?php
declare(strict_types=1);

// PHP 8.2+ with ext-curl. For PHP-FPM/CGI workers on ONE server.
// Use a persistent LOCAL directory outside the web root, accessible only to
// the application OS account (0700 on Unix, equivalent Windows ACLs).
// Every request must use the same directory and this helper's file locking.
final class VinqueryTokenCache
{
    private string $cacheFile;
    private array $credentials;
    private Closure $fetch;
    private Closure $clock;

    public function __construct(string $clientId, string $clientSecret, string $audience, string $cacheDirectory)
    {
        if (trim($clientId) === '' || trim($clientSecret) === '' || trim($audience) === '') {
            throw new InvalidArgumentException('Client ID, client secret and audience are required.');
        }
        $directory = realpath($cacheDirectory);
        if ($directory === false || !is_dir($directory) || !is_writable($directory)) {
            throw new InvalidArgumentException('Create a private, writable cache directory first.');
        }
        $this->credentials = compact('clientId', 'clientSecret', 'audience');
        // Rotation or another audience gets a separate cache; no secrets in filenames.
        $key = hash('sha256', json_encode($this->credentials, JSON_THROW_ON_ERROR));
        $this->cacheFile = $directory . DIRECTORY_SEPARATOR . 'vinquery-' . $key . '.json';
        $this->fetch = fn(): array => $this->requestToken();
        $this->clock = fn(): float => microtime(true);
    }

    public function getToken(): string
    {
        return $this->withCache(function (array &$cached): string {
            $now = ($this->clock)();
            if (is_string($cached['token'] ?? null) && trim($cached['token']) !== ''
                && is_numeric($cached['refreshAt'] ?? null) && $now < $cached['refreshAt']) {
                return $cached['token'];
            }
            // This runs under the lock, after reading the latest saved cache.
            $result = ($this->fetch)();
            $token = $result['jwtToken'] ?? null;
            $rawExpiry = $result['expiresUtc'] ?? null;
            try {
                if (!is_string($rawExpiry) || !preg_match('/T.*(?:Z|[+-]\d{2}:\d{2})$/', $rawExpiry)) {
                    throw new RuntimeException();
                }
                $expires = new DateTimeImmutable($rawExpiry);
                $parseErrors = DateTimeImmutable::getLastErrors();
                if ($parseErrors !== false && ($parseErrors['warning_count'] || $parseErrors['error_count'])) {
                    throw new RuntimeException();
                }
                $expiresAt = (float)$expires->format('U.u');
            } catch (Exception $error) {
                throw new RuntimeException('VINquery returned an invalid expiry.');
            }
            $now = ($this->clock)();
            if (!is_string($token) || trim($token) === '' || $expiresAt <= $now) {
                throw new RuntimeException('VINquery returned an empty token or invalid expiry.');
            }
            $cached = ['token' => $token, 'refreshAt' => $expiresAt - min(60.0, ($expiresAt - $now) * 0.1)];
            return $token;
        });
    }

    public function invalidate(string $rejectedToken): void
    {
        $this->withCache(function (array &$cached) use ($rejectedToken): void {
            if (($cached['token'] ?? null) === $rejectedToken) $cached = [];
        });
    }

    private function withCache(Closure $action): mixed
    {
        $file = fopen($this->cacheFile, 'c+b'); // Do not truncate before locking.
        if ($file === false) throw new RuntimeException('Cannot open the private token cache.');
        try {
            if (PHP_OS_FAMILY !== 'Windows' && !chmod($this->cacheFile, 0600)) {
                throw new RuntimeException('Cannot restrict token cache permissions.');
            }
            $deadline = hrtime(true) + 35_000_000_000;
            while (!flock($file, LOCK_EX | LOCK_NB)) {
                if (hrtime(true) >= $deadline) throw new RuntimeException('Timed out waiting for token renewal.');
                usleep(10_000);
            }
            rewind($file);
            $stored = stream_get_contents($file);
            if ($stored === false) throw new RuntimeException('Cannot read the token cache.');
            $cached = json_decode($stored, true);
            $cached = is_array($cached) ? $cached : [];
            $before = $cached;
            $result = $action($cached);
            if ($cached !== $before) {
                $serialized = json_encode($cached, JSON_THROW_ON_ERROR);
                if (!rewind($file) || !ftruncate($file, 0)
                    || fwrite($file, $serialized) !== strlen($serialized) || !fflush($file)) {
                    throw new RuntimeException('Cannot save the token cache.');
                }
            }
            return $result;
        } finally {
            // Close releases the lock, including after HTTP/parse/write failures.
            fclose($file);
        }
    }

    private function requestToken(): array
    {
        $curl = curl_init('https://identity.vinquery.com/connect/token');
        if ($curl === false) throw new RuntimeException('Cannot initialize the token request.');
        try {
            curl_setopt_array($curl, [
                CURLOPT_POST => true,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
                CURLOPT_POSTFIELDS => json_encode($this->credentials, JSON_THROW_ON_ERROR),
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 10,
                CURLOPT_TIMEOUT => 30,
            ]);
            $body = curl_exec($curl);
            $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
            if ($body === false || $status < 200 || $status >= 300) {
                throw new RuntimeException('VINquery token request failed (HTTP ' . $status . ').');
            }
            $result = json_decode($body, true);
            if (!is_array($result)) throw new RuntimeException('VINquery returned an invalid token response.');
            return $result;
        } finally {
            unset($curl); // Release the CurlHandle (PHP 8+).
        }
    }
}

3D. Python: create one provider per worker

Download vinquery_token_cache.py for Python 3.10+. It uses only the standard library. A thread lock makes waiting callers reuse the token obtained by the first caller.

Put this in a module imported by your handlers:

import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from vinquery_token_cache import TokenProvider

# Create once at module scope, outside views and VIN-processing loops.
tokens = TokenProvider(
    os.environ['VINQUERY_CLIENT_ID'],
    os.environ['VINQUERY_CLIENT_SECRET'],
    'vinquery:api:vindecode'
)

def decode_vin(vin: str) -> bytes:
    url = 'https://vindecode.vinquery.com/v3?' + urlencode({
        'VIN': vin, 'reportType': '3', 'format': 'JSON'
    })
    jwt = tokens.get_token()
    request = Request(url, headers={'Authorization': f'Bearer {jwt}'})
    with urlopen(request, timeout=30) as response:
        return response.read()

Import this same module in Flask or Django views. Each worker process has its own cache. This helper performs blocking I/O: in an async handler, use await asyncio.to_thread(decode_vin, vin) so the full synchronous call runs outside the event loop. Keep the provider shared.

View the complete Python cache helper
"""Python 3.10+, standard library only. Server-side synchronous helper.
Create ONE TokenProvider per credential/audience at application startup.
"""
import json
import re
import threading
import time
from datetime import datetime
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


class TokenProvider:
    def __init__(self, client_id: str, client_secret: str, audience: str):
        if not all(isinstance(x, str) and x.strip()
                   for x in (client_id, client_secret, audience)):
            raise ValueError("Client ID, client secret and audience are required.")
        self._credentials = dict(clientId=client_id, clientSecret=client_secret,
                                 audience=audience)
        self._lock = threading.Lock()
        self._token = None
        self._refresh_at = 0.0

    def get_token(self) -> str:
        # Check AFTER acquiring the lock: waiting callers reuse the replacement.
        with self._lock:
            if self._token and time.time() < self._refresh_at:
                return self._token
            request = Request(
                "https://identity.vinquery.com/connect/token",
                data=json.dumps(self._credentials).encode("utf-8"),
                headers={"Content-Type": "application/json"}, method="POST")
            try:
                with urlopen(request, timeout=30) as response:
                    result = json.load(response)
            except HTTPError as error:
                status = error.code
                error.close()
                raise RuntimeError(f"VINquery token request failed (HTTP {status}).") from None
            except URLError:
                raise RuntimeError("VINquery token request failed.") from None
            except (ValueError, UnicodeError):
                raise RuntimeError("VINquery returned an invalid token response.") from None

            try:
                token = result["jwtToken"]
                # .NET can return seven fractional digits; Python 3.10 accepts
                # three or six. Truncate/pad to microseconds before parsing.
                raw_expiry = re.sub(r"\.(\d+)(?=Z$|[+-]\d{2}:\d{2}$)",
                                    lambda m: "." + m[1].ljust(6, "0")[:6], result["expiresUtc"])
                expiry = datetime.fromisoformat(raw_expiry.replace("Z", "+00:00"))
                now = time.time()
                expires_at = expiry.timestamp()
                if (not isinstance(token, str) or not token.strip()
                        or expiry.tzinfo is None or expires_at <= now):
                    raise ValueError()
            except (KeyError, TypeError, AttributeError, ValueError, OverflowError, OSError):
                raise RuntimeError("VINquery returned an empty token or invalid expiry.") from None

            self._token = token
            self._refresh_at = expires_at - min(60.0, (expires_at - now) * 0.1)
            return token

    def invalidate(self, rejected_token: str) -> None:
        with self._lock:
            # A late 401 for an old token must not remove a newer token.
            if self._token == rejected_token:
                self._token = None
                self._refresh_at = 0.0

3E. Java: share a thread-safe provider

Download VinqueryTokenCache.java for Java 11+. The helper uses the JDK HTTP client and Jackson 2.x databind for JSON. Add it to your application's package and place the same package declaration at the top of the downloaded file. Its synchronized methods coordinate renewal and invalidation.

For a plain Maven project, add Jackson databind: use your framework's managed Jackson 2.x version when available; otherwise select a current patch from the supported Jackson 2.x releases.

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.21.6</version>
</dependency>

The standalone example above uses Jackson 2.21.6. Omit version when your framework's dependency management supplies a compatible Jackson 2.x version. Jackson 3 uses different packages; this download specifically requires Jackson 2.x.

// Application startup: retain both objects and inject them into your API client.
var http = java.net.http.HttpClient.newBuilder()
    .connectTimeout(java.time.Duration.ofSeconds(10)).build();
var tokens = new VinqueryTokenCache(http,
    System.getenv("VINQUERY_CLIENT_ID"),
    System.getenv("VINQUERY_CLIENT_SECRET"),
    "vinquery:api:vindecode");

// Inside your existing API method (vinqueryApiUri is your resource API URI):
String jwt = tokens.getToken();
var request = java.net.http.HttpRequest.newBuilder(vinqueryApiUri)
    .timeout(java.time.Duration.ofSeconds(30))
    .header("Authorization", "Bearer " + jwt).GET().build();
var response = http.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
Spring Boot: register one shared bean

Add this configuration to your application's package and constructor-inject VinqueryTokenCache into your existing service. A bean is shared within its Spring container by default. This example uses Jackson 2.x as described above.

import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;

@Configuration
class VinqueryConfiguration {
    @Bean
    VinqueryTokenCache vinqueryTokens(Environment env) {
        return new VinqueryTokenCache(
            HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(),
            env.getRequiredProperty("VINQUERY_CLIENT_ID"),
            env.getRequiredProperty("VINQUERY_CLIENT_SECRET"),
            "vinquery:api:vindecode");
    }
}

Create separate long-lived providers for other credential/audience pairs. Do not instantiate this class inside each controller method. Let InterruptedException propagate, or restore the thread's interrupted flag if you catch it.

View the complete Java cache helper
// Java 11+ with Jackson 2.x databind. Server-side only.
// Keep ONE instance per credential/audience for the application's lifetime.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Clock;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.Objects;

public final class VinqueryTokenCache {
    private final HttpClient http;
    private final ObjectMapper json = new ObjectMapper();
    private final Map<String, String> credentials;
    private final Clock clock;
    private String token;
    private long refreshAt;

    public VinqueryTokenCache(HttpClient http, String clientId, String clientSecret, String audience) {
        this(http, clientId, clientSecret, audience, Clock.systemUTC());
    }

    // Package-private clock overload for offline tests.
    VinqueryTokenCache(HttpClient http, String clientId, String clientSecret, String audience, Clock clock) {
        for (String value : new String[] { clientId, clientSecret, audience }) {
            if (value == null || value.isBlank())
                throw new IllegalArgumentException("Client ID, client secret and audience are required.");
        }
        this.http = Objects.requireNonNull(http);
        this.clock = Objects.requireNonNull(clock);
        this.credentials = Map.of("clientId", clientId, "clientSecret", clientSecret, "audience", audience);
    }

    public synchronized String getToken() throws IOException, InterruptedException {
        // Check under the same lock used for renewal and invalidation.
        if (token != null && clock.millis() < refreshAt) return token;
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://identity.vinquery.com/connect/token"))
            .timeout(Duration.ofSeconds(30))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(credentials)))
            .build();
        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() < 200 || response.statusCode() >= 300)
            throw new IOException("VINquery token request failed (HTTP " + response.statusCode() + ").");
        try {
            JsonNode result = json.readTree(response.body());
            if (result == null || !result.path("jwtToken").isTextual() || !result.path("expiresUtc").isTextual())
                throw new IllegalArgumentException();
            String replacement = result.path("jwtToken").textValue();
            long expiresAt = OffsetDateTime.parse(result.path("expiresUtc").textValue()).toInstant().toEpochMilli();
            long now = clock.millis();
            if (replacement.isBlank() || expiresAt <= now) throw new IllegalArgumentException();
            long buffer = Math.min(60_000L, (expiresAt - now) / 10);
            token = replacement;
            refreshAt = expiresAt - buffer;
            return token;
        } catch (IOException | IllegalArgumentException | DateTimeParseException | ArithmeticException error) {
            // Do not include response bodies (which can contain tokens) in errors.
            throw new IOException("VINquery returned an empty token or invalid expiry.");
        }
    }

    public synchronized void invalidate(String rejectedToken) {
        if (token != null && token.equals(rejectedToken)) {
            token = null;
            refreshAt = 0;
        }
    }
}

3F. Go: reuse one provider across goroutines

Download vinquery_token_cache.go into a vinquery directory in your Go module. It uses only the standard library. Import it using your module's path, such as example.com/yourapp/vinquery.

// In an initialization function returning error (called once at startup):
client := &http.Client{Timeout: 30 * time.Second}
tokens, err := vinquery.NewTokenProvider(client,
    os.Getenv("VINQUERY_CLIENT_ID"),
    os.Getenv("VINQUERY_CLIENT_SECRET"),
    "vinquery:api:vindecode")
if err != nil { return err }

// Pass the same *TokenProvider to your handlers. Inside an API method:
jwt, err := tokens.GetToken(ctx)
if err != nil { return err }
request, err := http.NewRequestWithContext(ctx, http.MethodGet, vinqueryAPIURL, nil)
if err != nil { return err }
request.Header.Set("Authorization", "Bearer " + jwt)
response, err := client.Do(request)
if err != nil { return err }
defer response.Body.Close()
// Check response.StatusCode and process response.Body.

The snippets belong in functions returning error, with net/http, os, time, and your vinquery package imported. In main(), handle startup errors instead of returning them. Pass your request context to GetToken; callers can cancel while waiting. Share the provider pointer, and create a separate provider for each credential/audience pair.

View the complete Go cache helper
// Server-side Go, standard library only. Keep ONE *TokenProvider per
// credential/audience at startup. Do not copy the provider by value.
package vinquery

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"
)

type TokenProvider struct {
	http        *http.Client
	credentials map[string]string
	gate        chan struct{}
	now         func() time.Time
	token       string
	refreshAt   time.Time
}

func NewTokenProvider(client *http.Client, clientID, clientSecret, audience string) (*TokenProvider, error) {
	if strings.TrimSpace(clientID) == "" || strings.TrimSpace(clientSecret) == "" || strings.TrimSpace(audience) == "" {
		return nil, errors.New("client ID, client secret and audience are required")
	}
	if client == nil {
		client = &http.Client{Timeout: 30 * time.Second}
	}
	return &TokenProvider{
		http: client, credentials: map[string]string{"clientId": clientID, "clientSecret": clientSecret, "audience": audience},
		gate: make(chan struct{}, 1), now: time.Now,
	}, nil
}

func (p *TokenProvider) GetToken(ctx context.Context) (string, error) {
	// Waiting callers may cancel without interrupting another caller's renewal.
	select {
	case p.gate <- struct{}{}:
		defer func() { <-p.gate }()
	case <-ctx.Done():
		return "", ctx.Err()
	}
	if err := ctx.Err(); err != nil {
		return "", err
	}
	if p.token != "" && p.now().Before(p.refreshAt) {
		return p.token, nil
	}
	body, err := json.Marshal(p.credentials)
	if err != nil {
		return "", err
	}
	requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()
	request, err := http.NewRequestWithContext(requestCtx, http.MethodPost,
		"https://identity.vinquery.com/connect/token", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	request.Header.Set("Content-Type", "application/json")
	response, err := p.http.Do(request)
	if err != nil {
		return "", errors.New("VINquery token request failed")
	}
	defer response.Body.Close()
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return "", fmt.Errorf("VINquery token request failed (HTTP %d)", response.StatusCode)
	}
	var result struct {
		Token   string    `json:"jwtToken"`
		Expires time.Time `json:"expiresUtc"`
	}
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		return "", errors.New("VINquery returned an invalid token response")
	}
	now := p.now()
	if strings.TrimSpace(result.Token) == "" || !result.Expires.After(now) {
		return "", errors.New("VINquery returned an empty token or invalid expiry")
	}
	buffer := result.Expires.Sub(now) / 10
	if buffer > time.Minute {
		buffer = time.Minute
	}
	p.token, p.refreshAt = result.Token, result.Expires.Add(-buffer)
	return p.token, nil
}

func (p *TokenProvider) Invalidate(rejectedToken string) {
	p.gate <- struct{}{}
	defer func() { <-p.gate }()
	if p.token == rejectedToken {
		p.token, p.refreshAt = "", time.Time{}
	}
}

3G. TypeScript: keep the provider in a server module

Download vinquery-token-cache.ts for a Node.js 22+ server application. It is the typed equivalent of the Node.js helper, with runtime validation of the token response. Keep it in server code, where your client secret remains private.

import { createTokenProvider } from './vinquery-token-cache.js';

// Module scope, outside all request handlers.
export const tokens = createTokenProvider({
  clientId: process.env.VINQUERY_CLIENT_ID ?? '',
  clientSecret: process.env.VINQUERY_CLIENT_SECRET ?? '',
  audience: 'vinquery:api:vindecode'
});

export async function decodeVin(vin: string): Promise<Response> {
  const url = new URL('https://vindecode.vinquery.com/v3');
  url.search = new URLSearchParams({ VIN: vin, reportType: '3', format: 'JSON' }).toString();
  const jwt = await tokens.getToken();
  return fetch(url, { headers: { Authorization: `Bearer ${jwt}` } });
}

For a standalone project compiled with tsc, install typescript and @types/node as development dependencies. Use strict: true, target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", and lib: ["ES2022", "DOM"] in tsconfig.json. For ESM, set "type": "module" in package.json; the .js import above targets the compiled output.

In an existing framework, keep its TypeScript configuration and import conventions. Export one provider from a server-only module and import it in your handlers. Each Node.js worker or serverless instance has its own cache.

View the complete TypeScript cache helper
// Server-side TypeScript with Node.js 22+ and DOM fetch types.
// Create ONE provider per credential/audience outside request handlers.
export interface TokenProviderOptions {
  clientId: string;
  clientSecret: string;
  audience: string;
}

export interface TokenProvider {
  getToken(): Promise<string>;
  invalidate(rejectedToken: string): void;
}

export function createTokenProvider({ clientId, clientSecret, audience }: TokenProviderOptions): TokenProvider {
  if (![clientId, clientSecret, audience].every(value => typeof value === "string" && value.trim())) {
    throw new Error("Client ID, client secret and audience are required.");
  }
  let cached: { token: string; refreshAt: number } | null = null;
  let refreshInFlight: Promise<string> | null = null;

  async function requestToken(): Promise<string> {
    const response = await fetch("https://identity.vinquery.com/connect/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ clientId, clientSecret, audience }),
      signal: AbortSignal.timeout(30_000)
    });
    if (!response.ok) {
      throw new Error(`VINquery token request failed (HTTP ${response.status}).`);
    }
    const result: unknown = await response.json();
    if (typeof result !== "object" || result === null) {
      throw new Error("VINquery returned an invalid token response.");
    }
    const { jwtToken, expiresUtc } = result as { jwtToken?: unknown; expiresUtc?: unknown };
    const expiresAt = typeof expiresUtc === "string" ? Date.parse(expiresUtc) : NaN;
    const now = Date.now();
    if (typeof jwtToken !== "string" || !jwtToken.trim()
        || !Number.isFinite(expiresAt) || expiresAt <= now) {
      throw new Error("VINquery returned an empty token or invalid expiry.");
    }
    cached = { token: jwtToken, refreshAt: expiresAt - Math.min(60_000, (expiresAt - now) * 0.1) };
    return cached.token;
  }

  async function getToken(): Promise<string> {
    if (cached && Date.now() < cached.refreshAt) return cached.token;
    if (!refreshInFlight) {
      refreshInFlight = requestToken().finally(() => { refreshInFlight = null; });
    }
    return refreshInFlight;
  }

  function invalidate(rejectedToken: string): void {
    if (cached?.token === rejectedToken) cached = null;
  }

  return { getToken, invalidate };
}

Background jobs and multiple servers

  • PHP: the PHP helper above persists the cache across requests on one host. Ordinary variables and static properties in typical PHP-FPM/CGI applications do not do this.
  • Batch jobs: create the token provider once before the VIN-processing loop. A separate process started for each VIN cannot share an ordinary in-memory cache.
  • Multiple workers or servers: each process may maintain its own cache. Three continuously running instances can reasonably obtain three tokens per renewal period. If frequent cold starts are causing excessive token requests, consider a shared server-side cache with a lock for renewal.
  • Credential rotation: replace or clear the corresponding cache when credentials change. Keep tokens and client secrets in trusted server-side memory or storage; avoid printing them in logs.

Handle a rejected token without a retry loop

If a resource API returns 401, pass the exact token that was rejected to the corresponding method below. The helpers preserve a newer token if another request has already refreshed it. Call the cache helper again to obtain a usable token.

LanguageInvalidate the rejected token
C# / Gotokens.Invalidate(jwt)
Node.js / TypeScript / Javatokens.invalidate(jwt)
Pythontokens.invalidate(jwt)
PHP$tokens->invalidate($jwt)

For a read-only GET, you can retry the API request once with that token. Stop if it fails again. Do not renew automatically on every 403 or other error; check the response and account permissions. For operations that change data, follow that API's retry and idempotency rules.

4. Check that the fix worked

  1. Start one instance with an empty cache. Count outgoing /connect/token calls without logging token values.
  2. Make ten API calls before expiry. Expect one token request and ten API requests for the same credential/audience pair.
  3. Repeat with concurrent API calls from an empty cache. Expect one token request, with the callers sharing its result.
  4. Wait until the renewal buffer. The next API call should obtain one replacement. Subsequent calls should reuse it.
  5. Check each instance separately. Restarts and additional workers may each produce a new token; that alone does not indicate a cache problem.

If your counts already look like this, your caching may be working correctly. Reply to the reminder with your language/framework, how many application instances run, and where your cache is created. We can help interpret the pattern. Please leave client secrets and JWT values out of the reply.

Further reference

Microsoft: dependency injection lifetimes · Node.js: built-in fetch · Contact the VINquery.com team