Developers
PHP SDK.
A dependency-free client for shops running their own PHP checkout, compatible with PHP 7.3 through 8.5. It owns the three things that are easy to get wrong, identifier hashing, HR1 request signing, and the fail-open timeout budget. Its two behavioural contracts are written so they cannot quietly swap places.
Install
No Composer dependencies, on purpose.
- Runtime
- PHP 7.3 through 8.5, with
ext-curlandext-json. That is the entire dependency list. Shops on old, frozen PHP are exactly the shops that need a fraud signal, so the SDK is written in PHP 7.3-compatible syntax. - Namespace
Heatrail\Sdk\, PSR-4, mapped to the archive’ssrc/directory.- Where it comes from
- The archive is served from the partner portal, and it is also bundled inside the WooCommerce plugin. It appears on the Downloads page once your application is approved, on the Free plan as much as on Pro. It is not on Packagist.
- Every release is built reproducibly from a tagged commit and its SHA-256 is published on the API reference. Check the archive against that value before you vendor it.
If you already run Composer, point a path repository at the extracted archive. If you do not, drop the sources in and register four lines of autoloader:
spl_autoload_register(function (string $class): void {
$prefix = 'Heatrail\\Sdk\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$file = __DIR__ . '/heatrail-sdk/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require $file;
}
});Client
Construct it once, from configuration.
HeatrailClient::forShop() is the options-array factory, friendlier than eleven positional arguments on a PHP version that has no named arguments. The constructor itself is public if you prefer it.
use Heatrail\Sdk\HeatrailClient;
$client = HeatrailClient::forShop(
$keyId, // hk_…
$secret, // signing secret, server-side only
$baseUrl, // https://api.heatrail.eu
[
'network_salt' => $networkSalt, // identifier hashing only
'timeout_ms' => 800, // default; must be positive
'queue' => $queue, // optional QueueStore
'logger' => $logger, // optional (level, message, context)
]
);network_salt- Needed only by
hashEmail()andhashPhone(). Pass an empty string if you hash identifiers yourself elsewhere. timeout_ms- Defaults to
HeatrailClient::DEFAULT_TIMEOUT_MS, which is 800. A non-positive value throws at construction rather than being accepted: cURL reads a zero timeout as no timeout at all, which would silently defeat the entire fail-open budget. Lower it if you like. Do not raise it far. queue- A
QueueStore. Without one, a failed check is logged and lost. With one, it is queued for replay. Section 05. logger- A PSR-3-shaped callable taking level, message and context. Highly recommended: it is how a silently degraded integration becomes visible.
transport- Defaults to the bundled cURL transport. Swappable, which is mainly how the SDK’s own tests avoid the network.
sleeper,clockandnonce_factoryexist for the same reason.
Hashing
null means omit. It does not mean empty.
hashEmail(?string $raw) and hashPhone(?string $raw, string $country) normalise the value and apply the stage-one HMAC with your network salt, returning 64 lowercase hex characters, or null.
null comes back for an empty value, a whitespace-only value, a known placeholder such as n/a or test@test.com, and anything unparseable. When it does, leave the field out of the payload entirely. Do not send an empty string. Do not substitute a fallback: a hash of a shared dummy would become one enormous node linking every guest checkout in the network to every other, and the lowest level of the SDK refuses to compute it at all.
Phone normalisation covers Estonian numbers. A number the client cannot parse returns null and the field is omitted rather than guessed. For broader coverage, normalise to E.164 yourself and hash the result with the same stage-one construction.
Two contracts
One method never throws. One always does.
The asymmetry is the design. Read it before writing code against either method.
check() is fail-open
$result = $client->check(array $payload, string $idempotencyKey): array;On success you get the API body verbatim, field for field as the API reference documents it. On any failure, timeout, connection error, non-2xx, unencodable payload, malformed JSON, you get the degraded shape instead:
['degraded' => true, 'score' => 0, 'reasons' => []]It never throws. The failure is logged, and the check is pushed onto the queue if one is configured. The score is zero because there is nothing to report, not because the order was assessed and cleared. Branch on degraded in your own code before you branch on score.
Figure 1
One failure is deliberately not queued: a 409. That means the API already holds this idempotency key, usually a checkout path that fires the same check twice, so there is nothing to replay. It is logged distinctly, carrying the API’s discriminator, so a benign race is visible as a benign race and a key collision in your own code is visible as a bug.
label() throws
use Heatrail\Sdk\HeatrailLabelException;
try {
$confirmed = $client->label(
$orderRef,
'fraud', // fraud | chargeback | real
$operatorIdentifier, // recorded as labelled_by
$idempotencyKey,
[
'reason_code' => 'stolen_card',
'evidence_type' => 'merchant_confirmation',
'order_value_bucket' => '100-250',
]
);
// ['ok' => true, 'incident_id' => '…']
} catch (HeatrailLabelException $e) {
// Tell the operator. Do not record the label as applied.
}A transport error, a non-2xx, malformed JSON, or even a 2xx that arrived without an incident identifier all raise HeatrailLabelException. The method returns a success shape only when the API confirmed the label, because a label is an explicit human decision, and fabricating one would corrupt a shared network on the strength of a network hiccup.
Labels are not queued. Retrying is the operator’s explicit choice, not a background job’s.
Two smaller methods
explain(string $orderRef)- Re-reads the score, reasons and check time for one of your own orders. Fails soft like
check(). Any failure returns['degraded' => true, 'status' => …]and it never throws, because a read-only convenience must not take down an order screen. It is not queued, because there is nothing to replay and the operator clicks again. health()- A signed liveness probe returning
['status' => 'up']or['status' => 'degraded']. Use it to verify credentials during setup. The queue drain calls it first on its own.
Queue
The queue is where fail-open stops meaning data loss.
Give the client a QueueStore and every failed check is captured with everything needed to replay it exactly: method, path, the exact body bytes, and the original idempotency key. The interface is deliberately tiny, so you can back it with whatever your shop already has.
interface QueueStore
{
/** @param array{method: string, path: string, body: string, idempotency_key: string, enqueued_at: string} $job */
public function push(array $job): void;
/** $sender returns true when delivered (row removed), false to keep it. */
public function drain(callable $sender): int;
}drainQueue($queue) probes health() first and skips the whole cycle unless the API reports up, so a recovering service is never hammered. Then it replays each job, backing off exponentially with jitter between consecutive failures. It returns the number of jobs successfully drained.
Two timestamps that must not be confused
Every send, original or replay, is signed with a fresh timestamp and a fresh nonce, because those are transport-level anti-replay and the API will reject a stale one. The job’s enqueued_at is ordering metadata and is never sent as a header. What makes a replay safe is the unchanged idempotency key: if the original check did reach the API and you merely missed the response, the API returns the stored response without re-running anything.
The reference store, and what a production store adds
FileQueueStore writes one JSON line per job under an exclusive lock, and rewrites the file once, at the end of a drain, so a crash mid-pass leaves the queue intact. It is single-host, with no size cap and no eviction. While the API is unreachable and checkouts keep failing open, that file grows and every pass re-attempts every job.
For production, implement the same two methods against your own table and add retention: an attempt counter, an age or attempt ceiling after which a job is parked rather than deleted, and a size cap. A queue that quietly loses records is worse than no queue at all. The WooCommerce plugin ships that store, and its page states the numbers it uses.
Worked example
A custom checkout, end to end.
One class wrapping the client, with the three call sites a real shop needs: score an order, label an outcome, drain the queue. Order stands in for whatever your own order object is.
<?php
declare(strict_types=1);
use Heatrail\Sdk\HeatrailClient;
use Heatrail\Sdk\HeatrailLabelException;
use Heatrail\Sdk\Queue\FileQueueStore;
final class FraudSignal
{
/** @var HeatrailClient */
private $client;
/** @var FileQueueStore */
private $queue;
public function __construct()
{
$this->queue = new FileQueueStore('/var/lib/heatrail/queue.jsonl');
$this->client = HeatrailClient::forShop(
(string) getenv('HEATRAIL_KEY_ID'),
(string) getenv('HEATRAIL_API_SECRET'),
(string) getenv('HEATRAIL_API_BASE'),
[
'network_salt' => (string) getenv('HEATRAIL_NETWORK_SALT'),
'queue' => $this->queue,
'logger' => static function (string $level, string $message, array $context): void {
error_log('[heatrail] ' . $level . ' ' . $message . ' ' . json_encode($context));
},
]
);
}
/**
* Call once, straight after the order row exists and before the order is
* handed to the payment provider.
*
* @return array{score: int, reasons: array}|null null = no signal
*/
public function scoreOrder(Order $order): ?array
{
$payload = [
'order_ref' => $order->reference, // ^[A-Za-z0-9._:-]{1,128}$
'ip' => $order->clientIp, // required
'context' => [
'product_risk_class' => $order->hasGiftCard ? 'gift_card' : 'physical',
'qty' => $order->itemCount,
'amount_bucket' => self::amountBucket($order->totalEur),
'account_age_class' => $order->isGuest ? 'new' : 'existing',
],
];
if ($order->sessionId !== null) {
$payload['session_ref'] = $order->sessionId;
}
// null means OMIT. Never send '', never hash a fallback.
$emailHash = $this->client->hashEmail($order->email);
if ($emailHash !== null) {
$payload['email_h'] = $emailHash;
}
$phoneHash = $this->client->hashPhone($order->phone, 'EE');
if ($phoneHash !== null) {
$payload['phone_h'] = $phoneHash;
}
$result = $this->client->check($payload, self::checkKey($order->reference));
// Fail-open: "no signal" is NOT "low risk". Say so in your own code.
if (! empty($result['degraded'])) {
return null;
}
return [
'score' => isset($result['score']) ? (int) $result['score'] : 0,
'reasons' => isset($result['reasons']) && is_array($result['reasons']) ? $result['reasons'] : [],
];
}
/**
* Returns false when Heatrail did not confirm. Show that to the operator
* and never record a label the API did not accept.
*/
public function applyLabel(Order $order, string $label, string $reasonCode, string $operator): bool
{
try {
$this->client->label(
$order->reference,
$label, // fraud | chargeback | real
$operator,
self::labelKey($order->reference, $label),
[
'reason_code' => $reasonCode, // ignored for 'real'
'evidence_type' => 'merchant_confirmation',
'order_value_bucket' => self::amountBucket($order->totalEur),
]
);
return true;
} catch (HeatrailLabelException $e) {
error_log('[heatrail] label not confirmed: ' . $e->getMessage());
return false;
}
}
/** Run from cron, once a minute. */
public function drain(): int
{
return $this->client->drainQueue($this->queue);
}
/**
* Deterministic per order, and built from a hash so that an order
* reference containing '.' or ':' still yields a key matching the API's
* ^[A-Za-z0-9_-]{16,128}$.
*/
private static function checkKey(string $orderRef): string
{
return 'chk-' . substr(hash('sha256', $orderRef . '|' . getenv('HEATRAIL_KEY_ID')), 0, 32);
}
private static function labelKey(string $orderRef, string $label): string
{
return 'lbl-' . substr(hash('sha256', $orderRef . '|' . $label), 0, 32);
}
private static function amountBucket(float $total): string
{
if ($total < 50) {
return '0-50';
}
if ($total < 100) {
return '50-100';
}
if ($total < 250) {
return '100-250';
}
return $total < 500 ? '250-500' : '500+';
}
}The example uses typed properties and nullable return types, so it wants PHP 7.4 or newer. The SDK itself stays 7.3-compatible, but your code does not have to.
And the call sites:
// In checkout, after the order exists:
$signal = new FraudSignal();
$assessment = $signal->scoreOrder($order);
if ($assessment !== null && $assessment['score'] >= 70) {
$order->flagForManualReview($assessment['reasons']);
}
// The order continues regardless. There is no else branch that stops a sale.// bin/heatrail-drain.php, from cron, every minute:
(new FraudSignal())->drain();