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

# Token Lifecycle

> Production-ready token caching, proactive refresh, and 401 retry patterns in Node.js, Python, Ruby, PHP, and C#.

Tokens are valid for \~1 hour. Fetching a new token on every request adds unnecessary latency and risks hitting rate limits. Production integrations should:

1. **Cache the access token** in memory along with its `expires_in` value
2. **Refresh proactively** when within 60 seconds of expiry
3. **On `401`**, invalidate the cache and fetch a new token, then retry the request once

The complete wrappers below handle all three cases and include gzip compression.

<CodeGroup>
  ```js Node.js theme={null}
  import { gzipSync } from 'zlib';

  let _cachedToken = null;
  let _tokenExpiresAt = 0;

  async function getAccessToken() {
    const now = Math.floor(Date.now() / 1000);
    if (_cachedToken && now < _tokenExpiresAt - 60) return _cachedToken;

    const res = await fetch(TOKEN_URL, {
      method: "POST",
      headers: {
        "Authorization": "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: "grant_type=client_credentials&scope=billreview%3Awrite",
    });
    if (!res.ok) throw new Error(`Token request failed: ${res.status}`);
    const data = await res.json();
    _cachedToken = data.access_token;
    _tokenExpiresAt = Math.floor(Date.now() / 1000) + (data.expires_in ?? 3600);
    return _cachedToken;
  }

  async function reviewBill(billPayload) {
    const compressed = gzipSync(Buffer.from(JSON.stringify(billPayload)));

    const post = async (token) => fetch(`${API_ENDPOINT}/v1/review`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${token}`,
        "x-api-key": API_KEY,
        "Content-Type": "application/json",
        "Content-Encoding": "gzip",
      },
      body: compressed,
    });

    let res = await post(await getAccessToken());
    if (res.status === 401) {
      _cachedToken = null;                    // invalidate and retry once
      res = await post(await getAccessToken());
    }
    if (!res.ok) throw new Error(`Review failed: ${res.status}`);
    // Node.js fetch automatically decompresses the gzip response
    return res.json();
  }
  ```

  ```python Python theme={null}
  import time, threading, requests, base64, gzip, json

  _token_lock = threading.Lock()
  _cached_token = None
  _token_expires_at = 0.0

  def get_access_token():
      global _cached_token, _token_expires_at
      with _token_lock:
          if _cached_token and time.time() < _token_expires_at - 60:
              return _cached_token
          credentials = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
          res = requests.post(
              TOKEN_URL,
              headers={
                  "Authorization": f"Basic {credentials}",
                  "Content-Type": "application/x-www-form-urlencoded",
              },
              data="grant_type=client_credentials&scope=billreview%3Awrite",
          )
          res.raise_for_status()
          data = res.json()
          _cached_token = data["access_token"]
          _token_expires_at = time.time() + data.get("expires_in", 3600)
          return _cached_token

  def review_bill(bill_payload):
      global _cached_token
      compressed = gzip.compress(json.dumps(bill_payload).encode())

      def _post(token):
          return requests.post(
              f"{API_ENDPOINT}/v1/review",
              headers={
                  "Authorization": f"Bearer {token}",
                  "x-api-key": API_KEY,
                  "Content-Type": "application/json",
                  "Content-Encoding": "gzip",
              },
              data=compressed,
          )

      res = _post(get_access_token())
      if res.status_code == 401:
          _cached_token = None               // invalidate and retry once
          res = _post(get_access_token())
      res.raise_for_status()
      return res.json()  # requests automatically decompresses the gzip response
  ```

  ```php PHP theme={null}
  <?php
  $cachedToken      = null;
  $tokenExpiresAt   = 0;

  function getAccessToken(): string {
      global $cachedToken, $tokenExpiresAt;
      if ($cachedToken && time() < $tokenExpiresAt - 60) {
          return $cachedToken;
      }
      $credentials = base64_encode(CLIENT_ID . ':' . CLIENT_SECRET);
      $ch = curl_init(TOKEN_URL);
      curl_setopt_array($ch, [
          CURLOPT_POST           => true,
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_HTTPHEADER     => [
              "Authorization: Basic $credentials",
              "Content-Type: application/x-www-form-urlencoded",
          ],
          CURLOPT_POSTFIELDS => "grant_type=client_credentials&scope=billreview%3Awrite",
      ]);
      $body   = curl_exec($ch);
      $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
      curl_close($ch);

      if ($status !== 200) throw new RuntimeException("Token request failed: $status");

      $data             = json_decode($body, true);
      $cachedToken      = $data['access_token'];
      $tokenExpiresAt   = time() + ($data['expires_in'] ?? 3600);
      return $cachedToken;
  }

  function reviewBill(array $billPayload): array {
      global $cachedToken;
      $compressed = gzencode(json_encode($billPayload));

      $post = function (string $token) use ($compressed): array {
          $ch = curl_init(API_ENDPOINT . '/v1/review');
          curl_setopt_array($ch, [
              CURLOPT_POST           => true,
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_ENCODING       => 'gzip',   // auto-decompresses the gzip response
              CURLOPT_HTTPHEADER     => [
                  "Authorization: Bearer $token",
                  "x-api-key: " . API_KEY,
                  "Content-Type: application/json",
                  "Content-Encoding: gzip",
              ],
              CURLOPT_POSTFIELDS => $compressed,
          ]);
          $body   = curl_exec($ch);
          $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
          curl_close($ch);
          return [$status, json_decode($body, true)];
      };

      [$status, $result] = $post(getAccessToken());
      if ($status === 401) {
          $cachedToken = null;                    // invalidate and retry once
          [$status, $result] = $post(getAccessToken());
      }
      if ($status !== 200) throw new RuntimeException("Review failed: $status");
      return $result;
  }
  ```

  ```ruby Ruby theme={null}
  require 'net/http'
  require 'uri'
  require 'json'
  require 'base64'
  require 'zlib'
  require 'stringio'
  require 'monitor'

  TOKEN_LOCK        = Monitor.new
  @cached_token     = nil
  @token_expires_at = 0.0

  def get_access_token
    TOKEN_LOCK.synchronize do
      return @cached_token if @cached_token && Time.now.to_f < @token_expires_at - 60

      uri = URI(TOKEN_URL)
      req = Net::HTTP::Post.new(uri)
      req['Authorization'] = "Basic #{Base64.strict_encode64("#{CLIENT_ID}:#{CLIENT_SECRET}")}"
      req['Content-Type']  = 'application/x-www-form-urlencoded'
      req.body = 'grant_type=client_credentials&scope=billreview%3Awrite'

      res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |h| h.request(req) }
      raise "Token request failed: #{res.code}" unless res.is_a?(Net::HTTPSuccess)

      data              = JSON.parse(res.body)
      @cached_token     = data['access_token']
      @token_expires_at = Time.now.to_f + (data['expires_in'] || 3600)
      @cached_token
    end
  end

  def post_review(compressed, token)
    uri = URI("#{API_ENDPOINT}/v1/review")
    req = Net::HTTP::Post.new(uri)
    req['Authorization']    = "Bearer #{token}"
    req['x-api-key']        = API_KEY
    req['Content-Type']     = 'application/json'
    req['Content-Encoding'] = 'gzip'
    req['Accept-Encoding']  = 'gzip'
    req.body = compressed
    Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |h| h.request(req) }
  end

  def review_bill(bill_payload)
    compressed = Zlib.gzip(bill_payload.to_json)

    res = post_review(compressed, get_access_token)
    if res.code == '401'
      @cached_token = nil              # invalidate and retry once
      res = post_review(compressed, get_access_token)
    end
    raise "Review failed: #{res.code}" unless res.is_a?(Net::HTTPSuccess)

    # Decompress gzip response body if the server returned one
    body = if res['content-encoding'] == 'gzip'
      Zlib::GzipReader.new(StringIO.new(res.body)).read
    else
      res.body
    end
    JSON.parse(body)
  end
  ```

  ```csharp C# theme={null}
  using System.IO.Compression;
  using System.Net;
  using System.Net.Http.Headers;
  using System.Text;
  using System.Text.Json;

  private static string? _cachedToken;
  private static DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue;
  private static readonly SemaphoreSlim _tokenLock = new(1, 1);

  private static async Task<string> GetAccessTokenAsync(HttpClient http)
  {
      await _tokenLock.WaitAsync();
      try
      {
          if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiresAt.AddSeconds(-60))
              return _cachedToken;

          var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{CLIENT_ID}:{CLIENT_SECRET}"));
          var req = new HttpRequestMessage(HttpMethod.Post, TOKEN_URL);
          req.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
          req.Content = new StringContent(
              "grant_type=client_credentials&scope=billreview%3Awrite",
              Encoding.UTF8, "application/x-www-form-urlencoded"
          );
          var res = await http.SendAsync(req);
          res.EnsureSuccessStatusCode();

          using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
          _cachedToken = doc.RootElement.GetProperty("access_token").GetString()!;
          var expiresIn = doc.RootElement.TryGetProperty("expires_in", out var ei) ? ei.GetInt32() : 3600;
          _tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
          return _cachedToken;
      }
      finally { _tokenLock.Release(); }
  }

  private static async Task<HttpResponseMessage> SendReviewRequestAsync(
      HttpClient http, byte[] compressed, string token)
  {
      var req = new HttpRequestMessage(HttpMethod.Post, $"{API_ENDPOINT}/v1/review");
      req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
      req.Headers.Add("x-api-key", API_KEY);
      var body = new ByteArrayContent(compressed);
      body.Headers.ContentType = new MediaTypeHeaderValue("application/json");
      body.Headers.ContentEncoding.Add("gzip");
      req.Content = body;
      return await http.SendAsync(req);
  }

  private static async Task<JsonDocument> ReviewBillAsync(HttpClient http, object billPayload)
  {
      var jsonBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(billPayload));
      using var ms = new MemoryStream();
      using (var gz = new GZipStream(ms, CompressionLevel.Optimal, leaveOpen: true))
          await gz.WriteAsync(jsonBytes);
      var compressed = ms.ToArray();

      var res = await SendReviewRequestAsync(http, compressed, await GetAccessTokenAsync(http));
      if (res.StatusCode == HttpStatusCode.Unauthorized)
      {
          _cachedToken = null;                // invalidate and retry once
          res = await SendReviewRequestAsync(http, compressed, await GetAccessTokenAsync(http));
      }
      res.EnsureSuccessStatusCode();
      // Manually decompress the gzip response body
      var bytes = await res.Content.ReadAsByteArrayAsync();
      using var ms = new MemoryStream(bytes);
      using var gz = new GZipStream(ms, CompressionMode.Decompress);
      return await JsonDocument.ParseAsync(gz);
  }
  ```
</CodeGroup>
