Playbook: Atrax X-Request-Hash / X-Timestamp request signing

Discovered 2026-08-12, resolves #5921. Applies to any Atrax (tronik.atrax4.com) API endpoint that returns 401 {"message":"Invalid request hash"} or 400 {"message":"Błędna sygnatura czasu"} when called with only a Bearer token.

The algorithm

Extracted from Atrax’s own public web app JS bundle (assets/j-atrax-*.js, served unauthenticated from https://tronik.atrax4.com/) — function appendAdditionalAuthorizationHashHeader:

X-Timestamp    = Date.now() in milliseconds, as a decimal string   (e.g. "1786527500979")
X-Request-Hash = SHA256(X-Timestamp + access_token), hex-encoded lowercase

Where access_token is the raw OAuth2 Bearer token value obtained from POST https://tronik.atrax4.com/oauth/token (no Bearer prefix, no quoting — just the token string concatenated directly after the timestamp string).

Verified twice, live:

  1. Replayed against a real browser-captured request/response pair (2026-08-12) — computed hash matched the captured x-request-hash byte-for-byte.
  2. Computed fresh, end-to-end, using our own service-account OAuth token (client_id/client_secret
    • password grant) against both previously-blocked endpoints — both returned 200 OK with real data, no browser involved.

Applying it — PowerShell

$timestampMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString()
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$hashInput = $timestampMs + $accessToken
$hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($hashInput))
$requestHash = -join ($hashBytes | ForEach-Object { $_.ToString("x2") })
 
$headers = @{
    "Authorization"  = "Bearer $accessToken"
    "X-Timestamp"    = $timestampMs
    "X-Request-Hash" = $requestHash
}

Node/n8n Code node equivalent (crypto stdlib, no extra dependency):

const crypto = require('crypto');
const timestampMs = Date.now().toString();
const requestHash = crypto.createHash('sha256').update(timestampMs + accessToken).digest('hex');
// headers: { Authorization: `Bearer ${accessToken}`, 'X-Timestamp': timestampMs, 'X-Request-Hash': requestHash }

Python equivalent:

import hashlib, time
timestamp_ms = str(int(time.time() * 1000))
request_hash = hashlib.sha256((timestamp_ms + access_token).encode()).hexdigest()

Timestamp freshness: not yet characterized how large a clock-skew/staleness window the server tolerates — generate the timestamp immediately before the request, don’t reuse one across retries or a batch loop.

Endpoints confirmed to need this signature

EndpointBody/query gotcha found along the way
GET /api/exploitationQuery params vehicleId, start, stop (unix seconds), useGps — no body gotcha, worked once signed correctly
POST /api/statistics/daily/driversBody must be {driverIds: [...], startTimeStamp, stopTimeStamp} (unix seconds) — not {driverIds, date} as originally guessed in 5921’s earlier attempts. The 400 "Błędna sygnatura czasu" error was misleading — it fired for the wrong body shape even before the signature was ever validated, which is why prior signature-guessing attempts against this endpoint never got anywhere. Fix required both the signature AND the body field names.

Endpoints that work without this signature (Bearer token only) — unaffected, don’t add the header there: /api/rtdrivers, /api/external/vehicles/, /api/rt/..., /api/rides/summary, /api/transport/filtr, /api/objects.

Response-model & persistence gotchas (found on the live spot-check, #6204)

Once signing worked, the first genuine end-to-end run against live data + the real DB exposed three insert-blocking bugs that JS-only smoke-tests missed. Watch for all of these when touching the atrax-daily-stats-collector / atrax-ecodriving-daily workflows:

  1. /api/statistics/daily/drivers buckets by 02:00-UTC op-day, keyed on day.timeStamp, and day.date is undefined. A 24h op-day window [D 02:00, D+1 02:00) returns two 02:00-anchored days buckets — op-day D (timeStamp === startTimeStamp) and the D+1 boundary. Flattening both under one fixed stat_date yields duplicate (driver_id, atrax_car_id, stat_date) rows → 409 on the create upsert. Keep only the bucket whose day.timeStamp === startTimeStamp. (GET /api/exploitation is a range-summary, not bucketed, so it does not have this problem — the op-day window is correct for stats.)

  2. p24_gps_daily_stats.distance_delta_km and .fuel_used_liters are GENERATED columns. POSTing a value 400s (428C9 cannot insert a non-DEFAULT value into a generated column). The DB derives them from base columns: distance_delta_km = (distance_end_m - distance_start_m)/1000, fuel_used_liters = (fuel_counter_end - fuel_counter_start)/10. Populate the base columns and never emit the generated ones. The Atrax odometer (summary.firstOdometer/lastOdometer) is in km (proven live: lastOdometer - firstOdometer === summary.totalDistance), so distance_*_m = odometer × 1000. A 0/absent odometer → null (never a false 0). summary carries no fuel counter, so fuel columns stay null.

  3. p24_gps_daily_stats unique index is (car_id, stat_date), not (atrax_car_id, stat_date). PostgREST upsert on_conflict=atrax_car_id,stat_date 400s with 42P10 (no matching constraint). Use on_conflict=car_id,stat_date. (p24_driver_ecodriving_daily conflict target is driver_id,atrax_car_id,stat_date.)

n8n node-config gotchas that hide behind a green run (found #5918, 2026-08-14)

Once signing + the DB model were correct, atrax-ecodriving-daily still wrote zero rows while reporting success — three separate n8n node-config bugs, each of which lets the workflow finish green without ever calling Atrax or writing data. All three fixed in PR #6302. Check every one when touching these workflows:

  1. IF v2 boolean equal vs false gotcha. The skip_if_no_drivers gate used operator:{type:boolean, operation:"equal"}, rightValue:false, typeValidation:strict. This routed every skip:false item to the false output (the dead/skip branch), so execution never reached post_ecodriving. Symptom: run finishes in <1s, lastNodeExecuted = the IF node, 0 rows. Fix: use the unary boolean operator {type:boolean, operation:"false", singleValue:true} with leftValue:"={{ $json.skip }}" (proceed when skip is false) — never equal/rightValue:false.
  2. httpRequest v4.2 contentType:"raw" needs rawContentType. A manual Content-Type: application/json header is ignored in raw mode; n8n sends text/plain → Atrax 415 Unsupported Media Type. Set the node param rawContentType: "application/json" (as the working upsert_stats/upsert_ecodriving nodes do). The signed POST also needs the tronik-webappinstanceid header (same static value the exploitation GET uses).
  3. Supabase getAll defaults to 50 rows. fetch_drivers_list without returnAll:true returned only 50 of ~260 drivers, so the eco-stats POST queried an inactive subset and Atrax returned an all-empty driverStatistic (every vehiclesSummary:[]) — looks like “no data for this date” even on a busy weekday. Set returnAll:true.

Backfilling a specific historical date: the n8n public /rest/workflows/{id}/run endpoint ignores node-code overrides passed in workflowData (it runs the saved def). To backfill, temporarily PUT the workflow with compute_yesterday hardcoded to the target op-day window (start = D 02:00 UTC, stop = D+1 02:00 UTC − 1s), run, then PUT the correct def back. The on_conflict upsert makes repeated backfills idempotent.

How this was found

  1. Live browser DevTools captures (two real requests pasted into a Claude Code session, 2026-08-12) included x-request-hash and x-timestamp headers absent from every prior automated attempt.
  2. Rather than reverse-engineering the hash algorithm from output pairs alone, fetched Atrax’s own public app shell (https://tronik.atrax4.com/) to find its JS bundle URLs, downloaded assets/j-atrax-*.js (~14 MB minified, unauthenticated static asset — no credentials needed to fetch it), and grepped for X-Request-Hash directly in the source. Found the exact signing function in one match.
  3. Verified against the captured example, then verified again with a fresh end-to-end self-signed call using our own credentials.

Takeaway for future similar problems: an SPA’s client-side signing logic is not a secret — the JS is served to any browser that loads the page. When a vendor’s API rejects unsigned requests, check whether their own frontend bundle is fetchable and greppable before assuming the algorithm is unrecoverable or requires vendor support / live browser automation.

Next steps (tracked on 6194)

  • Update atrax-daily-stats-collector and atrax-ecodriving-daily (n8n, bms-4) to add this signing to their fetch_exploitation/post_ecodriving HTTP Request nodes, and fix the ecodriving body shape (startTimeStamp/stopTimeStamp, not date).
  • Once live, p24_gps_daily_stats and p24_driver_ecodriving_daily start populating for the first time — unblocks Track A/C/D on #6138 and Phase 7 on #6194 without needing the EXTERNAL_API role grant or any browser automation.