Heatrail

Developers

API reference.

JSON over HTTPS, authenticated with an HR1 HMAC signature over a canonical string. Two endpoints carry the everyday integration, /v1/check and /v1/labels. Every request body is validated against a strict allowlist, so an unknown field is an error rather than a quietly accepted new category of stored data.

01

Basics

One origin, one version prefix.

Base URL
https://api.heatrail.eu. Read it from configuration rather than pasting it into your code: it is the one value that would have to change everywhere at once.
Content type
application/json on every request with a body. Responses are always JSON, including errors.
Server-side only
The signing secret and the network salt must never reach a browser, a mobile app, or any client you do not control. There is no public or publishable key.
Scopes
A key carries check, label, or both. /v1/check, the explain endpoint and the history scan need check, and /v1/labels and its reversal need label.
Latency
Measured in production on 1 September 2026: median round trip 386 ms, 95th percentile 610 ms, slowest 799 ms. Bound your own call at 800 ms and let the order proceed when the budget runs out. That budget is the contract the rest of this page is written against.

Figure 1

One check, from a signed request to one of three endings, all of which let the order through.On the left a box labelled Shop server says it signs five lines with an HR1 HMAC SHA-256 signature and holds an 800 ms budget. An arrow marked POST slash v1 slash check leads to a box labelled Heatrail, which verifies the signature, validates the body and scores the checkout. Two arrows leave that box. The upper one reaches a box marked 200, carrying a score from 0 to 100 and reasons. The lower one reaches a box marked 422, invalid payload, whose details field names every field at fault. A third arrow drops from the shop box into a row of three boxes along the foot: 800 ms elapses and the client stops waiting, the order completes with no signal, and the check is queued for replay under the same idempotency key. A closing line reads that the order goes through in all three branches and that Heatrail never stops, delays or reverses a purchase.One checkout, one checkShop serversigns five linesHR1 HMAC SHA-256800 ms budgetPOST /v1/checkHeatrailverifies the signaturevalidates the bodyscores the checkout200the check is answeredscore 0 to 100 and reasons422the body is refuseddetails names every fieldno answer in time800 ms elapsesthe client stops waitingThe order completesfail open, no signalThe check is queuedreplayed with the same keyThe order goes through in all three branches. Heatrail never stops, delays or reverses a purchase.
One check has three endings, and the order goes through in all three.
02

Authentication

HR1: sign five lines, send three headers.

Authorization:  HR1 {key_id}:{signature}
X-HR-Timestamp: 1788048000
X-HR-Nonce:     9f2b7c1a-4d3e-4a8b-9c6d-2e1f0a3b5c7d
Content-Type:   application/json
Authorization
The literal scheme HR1, your key ID, a colon, and the signature. The key ID matches ^[A-Za-z0-9_]{1,64}$, and the signature is 64 lowercase hexadecimal characters.
X-HR-Timestamp
Unix seconds as a decimal string. It must be within ±300 seconds of our clock, so keep NTP running. A skewed timestamp is refused with the same generic 401 as a bad signature.
X-HR-Nonce
A UUID, in the standard 8-4-4-4-12 hexadecimal form. It is single-use: presenting the same nonce twice within its ten-minute retention is refused. Generate a fresh one for every request, including every retry.
The nonce store keeps no history across a restart. A request whose timestamp is older than the store’s last restart cannot be checked for replay, so it is refused rather than trusted. This reaches only requests signed shortly before a restart and delivered after it. Sign again with a current timestamp and a fresh nonce and the retry goes through.

The canonical string

Five fields joined by single line feeds, in exactly this order, with no trailing newline:

{timestamp}\n{nonce}\n{METHOD}\n{canonical_uri}\n{hex(sha256(raw_body))}
METHOD
Uppercased ASCII: POST, GET.
canonical_uri
The path exactly as it appears on the wire, never percent-decoded, never re-encoded, and never including the host or scheme.
If there is a query string, append ? and the canonical query. Split the raw query on &. A part with no = is treated as key=. Sort the pairs byte-wise by encoded key and then by encoded value, keeping duplicates as separate pairs, and rejoin with &. Nothing is decoded along the way.
A space must be encoded as %20, never as +. The signer does not normalise between them, so the two would produce different signatures.
hex(sha256(raw_body))
Lowercase hex SHA-256 of the raw request body bytes, the exact bytes you put on the wire, not a re-serialisation. An empty body still hashes, to e3b0c442…b7852b855.
Signature
HMAC-SHA256(key = your API secret, message = canonical string), lowercase hex.

A published test vector

These are the cross-implementation fixtures the SDK and the API both assert against. An implementation that reproduces the signature below is correct.

<?php

function heatrail_sign(
    string $secret,
    string $timestamp,
    string $nonce,
    string $method,
    string $uri,
    string $body
): string {
    $canonical = implode("\n", [
        $timestamp,
        $nonce,
        strtoupper($method),
        $uri,                      // path (+ canonical query), never the host
        hash('sha256', $body),     // an empty body still hashes
    ]);

    return hash_hmac('sha256', $canonical, $secret);
}

$signature = heatrail_sign(
    's3cr3t-alpha',
    '1788048000',
    '9f2b7c1a-4d3e-4a8b-9c6d-2e1f0a3b5c7d',
    'POST',
    '/v1/check',
    '{"order_ref":"o1"}'
);

// 853010ed8094da1839a701ddb4a06b0baf1dfb51f0d2777944d2002f7ba045a6

The canonical string that produces it, with the line feeds made visible:

1788048000
9f2b7c1a-4d3e-4a8b-9c6d-2e1f0a3b5c7d
POST
/v1/check
83946f872d419e30de623c62e8719ae14ae9cc59e38a81308e691837954e739c

Every authentication failure is one generic 401

An unknown key, a bad signature, a skewed clock, a replayed nonce and a scope mismatch all answer 401 unauthorized, with no detail. A distinct “insufficient scope” response would confirm to an attacker that the signature was otherwise valid, which is an oracle. When debugging, check in this order: key ID, secret, clock, and whether you signed the exact bytes you sent.

03

Idempotency

Every POST carries a key. It is not optional.

Send Idempotency-Key on every POST. It must match ^[A-Za-z0-9_-]{16,128}$, note that the allowed characters are narrower than an order reference’s, so derive the key rather than pasting a reference into it. Keys are scoped to your shop, the method and the path, and are retained for 24 hours.

Same key, same body, response stored
The stored response is returned verbatim, with the header Idempotency-Replayed: true. Side effects run exactly once.
Same key, different body
409 idempotency_key_reuse. This is a bug in the caller: the key identifies a specific request, and a different body under the same key means two different requests collided.
Same key, first request still in flight
409 idempotency_in_progress. We do not block waiting for the twin request. Retry after it lands.
Server error
A 5xx releases the claim instead of storing it, so a transient failure does not lock a key out for 24 hours. Retry it.
Missing or malformed
No header at all is 400 idempotency_key_required. A header that does not match the pattern is 422 idempotency_key_invalid.

Derive the key deterministically from the order, for example 'chk-' . substr(hash('sha256', $orderRef . '|' . $keyId), 0, 32). So a replayed check is recognised as the same request rather than counted twice.

04

POST /v1/check

Score one checkout. Scope: check.

Unknown keys are rejected, at the top level, inside context, and inside device_vector alike. All validation problems are collected and returned together, and messages name fields without ever echoing the values you submitted.

Body

order_ref, session_ref
Strings matching ^[A-Za-z0-9._:-]{1,128}$. At least one must be present and non-null. Every check is anchored to a real checkout you can be asked about, which is what stops the API from being a free lookup oracle over the shared signal.
ip
Required. The client IP address your own server observed, IPv4 or IPv6. It is taken from the body and never from a forwarding header: at our edge, your server is the proxy.
email_h, phone_h
Optional. Stage-one hashes: exactly 64 lowercase hexadecimal characters, or null, or absent. Never an empty string, and never the hash of a placeholder. Those are refused with 422 placeholder_hash.
persist_id
Optional returning-browser pseudonym, matching ^[a-f0-9]{16,64}$.
context
Optional object. Allowed keys: product_risk_class, qty, amount_bucket, account_age_class, referrer_class. qty is a non-negative integer, and the others are strings of at most 64 characters, or null.
These are free-form category strings, and the rules match on the exact value, so send your shop's accurate classification using this vocabulary rather than your own spelling of it: product_risk_class as physical, digital or gift_card, and account_age_class as new or existing. Send every field you have. Which of them the current rules act on, and how much each one counts, is not published, and a field that is quiet today may not be tomorrow.
device_vector
Optional object, at most 2 KB serialised. Allowed keys, and only these: screen_w, screen_h, dpr, cores, memory, touch, tz, languages, platform, ua_class, canvas_hash, webgl_hash.
Values are scalars of at most 64 characters, except languages, which is an array of at most 8 strings. A raw user-agent string has no allowed key and cannot be sent.

Request

POST /v1/check HTTP/1.1
Authorization: HR1 hk_9f4c1e08b7a2d631:{signature}
X-HR-Timestamp: 1788048000
X-HR-Nonce: 9f2b7c1a-4d3e-4a8b-9c6d-2e1f0a3b5c7d
Idempotency-Key: chk-6b1d0a94c7e25f38a0d41b7c9e3f5028
Content-Type: application/json

{
  "order_ref": "ord-10241",
  "session_ref": "sess-7f3c1b9e2a6d",
  "email_h": "64520c679c209de4dd56c590782e441fcfcd37ef6e3222a61da6d360b131de23",
  "ip": "203.0.113.42",
  "context": {
    "product_risk_class": "digital",
    "qty": 3,
    "amount_bucket": "100-250",
    "account_age_class": "new"
  },
  "device_vector": {
    "screen_w": 390,
    "screen_h": 844,
    "dpr": 3,
    "touch": true,
    "tz": "Europe/Tallinn",
    "languages": ["et", "en"],
    "ua_class": "mobile-ios",
    "canvas_hash": "1f4b9c07"
  }
}

Response, 200

{
  "check_id": "3f7a1c02-5b4e-4a19-8c3d-9e0b6d2f4a71",
  "score": 72,
  "reasons": [
    "Customer identifier matched a network-confirmed fraud trail, 2 shop(s) affected, 3 d ago",
    "Same identifier seen in 3 shops within 24 h"
  ],
  "trail_alias": "9c1d4e7b0a52f83e",
  "test": false,
  "plan": "pro",
  "network_signals_withheld": false
}
score
Integer, 0 to 100, produced by rules. 70 and above is the threshold at which Heatrail alerts the shop that placed the order.
reasons
Human-readable evidence lines with counts and recency, meant for a person to read and for your own code to log rather than to parse. They are sentences, not codes, and their wording is not a stable interface. A reason may say what kind of match fired, across how many shops, and how long ago in coarse units. It never carries an identifier, a hash, another shop’s evidence, or any shop’s identity. A count spans other shops and names none of them, which makes it a category rather than a record.
trail_alias
A 16-character opaque handle for the matched trail, or null when the check matched nothing. It is stable for you and different for every other shop looking at the same trail. Always null on the Free plan: the handle belongs to the network view.
plan
"free" or "pro", the plan this shop is on at the moment of the check. Both plans run the same check in the same second. What differs is whether signals contributed by other shops are part of the answer.
network_signals_withheld
Present only when the request carried an order_ref. On Pro it is always false, because nothing is withheld from Pro and any network reason is already in reasons. On Free, true means the network held something about this order and your plan did not show it. It is one boolean and it never carries a count, an age, a shop or an identifier.
test
true when this was a simulated test-mode response: no heat, no velocity, no graph, no alert, no billing.

Response, 422

{
  "error": "invalid_payload",
  "details": [
    "unknown field \"customer_name\"",
    "ip is required",
    "device_vector contains unknown key \"userAgent\""
  ]
}
05

POST /v1/labels

Report the outcome. Scope: label.

order_ref
Required. ^[A-Za-z0-9._:-]{1,128}$. The order in your own shop.
label
Required. One of fraud, chargeback, real. Only the first two create heat, and real creates none at all.
labelled_by
Required. Printable characters, at most 128, whoever in your organisation made the decision. Send whatever your own audit trail needs, an account name or a staff reference rather than an e-mail address if that is enough for you. We cannot verify the value, so it is recorded as an assertion rather than as a checked identity.

The permanent, append-only chain records an opaque actor reference and never the value itself. The readable value you send is kept beside the chain in a separate provenance record with a retention window of 90 days, after which it is deleted while the chain and its integrity proof stay. The value also persists on the incident itself for as long as that incident is active evidence.
evidence_type
Required. One of merchant_confirmation, chargeback, psp_event, merchant_workflow, manual_review.
reason_code
Required for fraud and chargeback, ignored for real. One of stolen_card, chargeback_received, account_takeover, reseller_abuse, other.
order_value_bucket
Optional. ^[A-Za-z0-9._:-]{1,32}$. A range, never an exact amount.
POST /v1/labels

{
  "order_ref": "ord-10241",
  "label": "fraud",
  "labelled_by": "anna.tamm",
  "reason_code": "stolen_card",
  "evidence_type": "merchant_confirmation",
  "order_value_bucket": "100-250"
}
{
  "ok": true,
  "incident_id": "b4e1c7d2-90a3-4f16-8e75-2c0d9a3b6f41",
  "label": "fraud",
  "status": "active"
}

Label semantics

One active label per order. Labels supersede rather than accumulate. Re-sending the same label is a no-op that still counts as re-affirming the decision. Moving from fraud to chargeback is a supersede, confidence rises, and the heat is never torn down in between.

Applying fraud or chargeback over an existing real is refused with 409 label_blocked_by_real. A cleared order must be explicitly reversed first, so that clearing a customer is never undone by accident.

A fraud or chargeback label makes the buyer’s device and IP hot for every Pro shop at once. E-mail and phone carry full weight at other shops only once they are verified or tied to that same device. Network heat requires two independently owned shops. An operator cannot write network heat.

06

Other endpoints

Reversal, explain, health, history scan.

POST /v1/labels/{incident}/reverse
Scope label. Reverses one of your own incidents, a correction, and the way to clear a real block before relabelling. Idempotent: a second reversal is a 200 no-op. Another shop’s incident identifier answers 404 not_found and never confirms that it exists.
GET /v1/orders/{order_ref}/explain
Scope check. Returns order_ref, score, reasons and an ISO 8601 checked_at for the most recent check on that order, for an admin “details” view.
Scoped to your own shop, and an unknown or foreign order reference always answers 404 not_found rather than 403, so the two are indistinguishable. Percent-encode the reference into the path and sign the encoded path byte-for-byte as sent.
GET /v1/health
Public. Answers {"status": "up"} or {"status": "degraded"} and nothing else. It never returns 401, even for an invalid signature, so it cannot be used to test whether a key is valid.
Signed with a valid check-scoped key, the response additionally carries ledger_seq for your own monitoring. That field is not public, because it would leak network volume.
POST /v1/history-scan/sessions
Scope check, Pro plan. A shop runs one history scan in its life. sessions opens it, sessions/{scan_ref}/rows submits past orders in requests of at most 100 rows, and sessions/{scan_ref}/complete closes it. An interrupted session resumes for seven days, and one scan compares at most 250,000 past orders against the network.
Every row is answered strong, weak or none against the row_ref you supplied, in categories rather than in evidence. No row is kept. A second scan takes an operator reset, and the reason for it is written to our audit log.
07

Errors

Every error is JSON with an error field.

400 idempotency_key_required
A POST arrived with no Idempotency-Key header.
401 unauthorized
Every authentication failure: unknown key, bad signature, timestamp outside the ±300 second window, reused nonce, missing or malformed header, or a key without the required scope. Deliberately undifferentiated.
403 key_frozen
The signature verified, but the key is frozen pending review. This is the one authentication outcome that is distinguishable, and only after the signature proved you hold the secret.
404 not_found
An explain or reversal target that does not exist, or belongs to another shop. The two cases are indistinguishable on purpose.
409 idempotency_key_reuse
The key is held by a request with a different body.
409 idempotency_in_progress
The key is claimed and the first request has not finished. Retry.
409 label_blocked_by_real
A fraud or chargeback label was applied over an active real. Reverse the real first.
422 invalid_payload
Validation failed. details lists every problem at once, naming fields but never echoing submitted values.
422 placeholder_hash
A submitted identifier hash is the hash of a known placeholder. Omit the field instead. The response does not say which field it was.
422 idempotency_key_invalid
The Idempotency-Key header does not match ^[A-Za-z0-9_-]{16,128}$.
429 rate_limited
Rate limit exceeded. The response carries Retry-After.

Rate limits

/v1/check is limited per API key to 20 requests in any single second and 60 requests in any single minute. Rejected requests count toward both windows. This is a brake on using the API at machine speed, not a capacity limit on selling: it sits far above the traffic of a shop calling us once per order.

Above the caps a key can also be frozen for review, which answers 403 key_frozen rather than 429. Treat it the same way you treat any other error here, as a degraded check, and write to us: a freeze is lifted by a person, not by waiting.

If you expect to exceed it legitimately, a large migration, a bulk backfill, tell us first at hello@heatrail.eu rather than discovering the limit in production.

Every error here is a degraded check

Catch all of them, keep the call inside the 800 ms budget from section 01, and let the order proceed. An error from this API is never a reason to stop a sale, and an integration that can turn one into a stopped sale carries the bug worth fixing first. The PHP SDK holds that contract for you.

08

Downloads

Verify your download.

The PHP SDK and the WooCommerce plugin are served from the partner portal as versioned ZIP archives. Each one is built from a tagged commit, reproducibly, so the same sources always produce the same bytes, and its SHA-256 checksum is published here and sent with every download in an X-Checksum-Sha256 header. Before you vendor either archive into your shop, check that the file you received is the file we built:

sha256sum heatrail-php-sdk-<version>.zip
# macOS: shasum -a 256 heatrail-php-sdk-<version>.zip

The output must match the value below exactly. If it does not, do not install the archive: download it again from the portal, and if the mismatch repeats tell us at hello@heatrail.eu. A checksum that fails is a build you did not get from us, not a corrupt download to work around.

heatrail-php-sdk-0.3.0.zip
7f6e5ca552e95eb8bbfd1d9cb3b27c1f2eff7d31aeb1aecd8c593db3f8765a32
heatrail-woocommerce-0.3.0.zip
54e90feb6b3c5c957e93f0e4b800544e667349c6604d25e109a0b22a984fc262

The AI-install prompt on the portal carries the same SDK checksum, so an agent that installs Heatrail for you verifies the download before it vendors a single file.

Or let the SDK hold the contract for you.

Heatrail

This site is not public yet

Heatrail is in a private pre-launch period. Enter the access password to continue.

Enquiries: hello@heatrail.eu