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 returns401 {"message":"Invalid request hash"}or400 {"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:
- Replayed against a real browser-captured request/response pair (2026-08-12) — computed hash
matched the captured
x-request-hashbyte-for-byte. - Computed fresh, end-to-end, using our own service-account OAuth token (
client_id/client_secretpasswordgrant) against both previously-blocked endpoints — both returned200 OKwith 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
| Endpoint | Body/query gotcha found along the way |
|---|---|
GET /api/exploitation | Query params vehicleId, start, stop (unix seconds), useGps — no body gotcha, worked once signed correctly |
POST /api/statistics/daily/drivers | Body 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:
-
/api/statistics/daily/driversbuckets by 02:00-UTC op-day, keyed onday.timeStamp, andday.dateisundefined. A 24h op-day window[D 02:00, D+1 02:00)returns two 02:00-anchoreddaysbuckets — op-dayD(timeStamp === startTimeStamp) and theD+1boundary. Flattening both under one fixedstat_dateyields duplicate(driver_id, atrax_car_id, stat_date)rows → 409 on thecreateupsert. Keep only the bucket whoseday.timeStamp === startTimeStamp. (GET /api/exploitationis a range-summary, not bucketed, so it does not have this problem — the op-day window is correct for stats.) -
p24_gps_daily_stats.distance_delta_kmand.fuel_used_litersare 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), sodistance_*_m = odometer × 1000. A0/absent odometer →null(never a false0).summarycarries no fuel counter, so fuel columns stay null. -
p24_gps_daily_statsunique index is(car_id, stat_date), not(atrax_car_id, stat_date). PostgREST upserton_conflict=atrax_car_id,stat_date400s with42P10(no matching constraint). Useon_conflict=car_id,stat_date. (p24_driver_ecodriving_dailyconflict target isdriver_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:
- IF v2 boolean
equalvsfalsegotcha. Theskip_if_no_driversgate usedoperator:{type:boolean, operation:"equal"},rightValue:false,typeValidation:strict. This routed everyskip:falseitem to the false output (the dead/skip branch), so execution never reachedpost_ecodriving. Symptom: run finishes in <1s,lastNodeExecuted= the IF node, 0 rows. Fix: use the unary boolean operator{type:boolean, operation:"false", singleValue:true}withleftValue:"={{ $json.skip }}"(proceed when skip is false) — neverequal/rightValue:false. - httpRequest v4.2
contentType:"raw"needsrawContentType. A manualContent-Type: application/jsonheader is ignored in raw mode; n8n sendstext/plain→ Atrax415 Unsupported Media Type. Set the node paramrawContentType: "application/json"(as the workingupsert_stats/upsert_ecodrivingnodes do). The signed POST also needs thetronik-webappinstanceidheader (same static value the exploitation GET uses). - Supabase
getAlldefaults to 50 rows.fetch_drivers_listwithoutreturnAll:truereturned only 50 of ~260 drivers, so the eco-stats POST queried an inactive subset and Atrax returned an all-emptydriverStatistic(everyvehiclesSummary:[]) — looks like “no data for this date” even on a busy weekday. SetreturnAll: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
- Live browser DevTools captures (two real requests pasted into a Claude Code session,
2026-08-12) included
x-request-hashandx-timestampheaders absent from every prior automated attempt. - 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, downloadedassets/j-atrax-*.js(~14 MB minified, unauthenticated static asset — no credentials needed to fetch it), and grepped forX-Request-Hashdirectly in the source. Found the exact signing function in one match. - 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-collectorandatrax-ecodriving-daily(n8n, bms-4) to add this signing to theirfetch_exploitation/post_ecodrivingHTTP Request nodes, and fix the ecodriving body shape (startTimeStamp/stopTimeStamp, notdate). - Once live,
p24_gps_daily_statsandp24_driver_ecodriving_dailystart populating for the first time — unblocks Track A/C/D on #6138 and Phase 7 on #6194 without needing theEXTERNAL_APIrole grant or any browser automation.