Heatrail

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.

01

Install

No Composer dependencies, on purpose.

Runtime
PHP 7.3 through 8.5, with ext-curl and ext-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’s src/ 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;
    }
});
02

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() and hashPhone(). 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, clock and nonce_factory exist for the same reason.
03

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.

04

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

What the SDK does when Heatrail does not answer: the sale continues and the check is kept for later.Along the top, a box labelled Checkout, where the order row already exists, leads by an arrow to a box labelled check, bounded at 800 ms and never throwing, which leads to a box reading any failure, whether a timeout, a non 2xx response or malformed JSON, answered with degraded true and a score of zero. A dashed arrow drops from the check box to a greyed box reading purchase stopped, and the note beside it says that dashed branch does not exist. A solid line runs from the failure box down to a row of three boxes along the foot: the check is queued with its exact bytes and original key, drained later after a health probe, and counted once because the same key returns the stored response. A closing line reads that the purchase is never stopped, delayed or reversed, and that what is missing is the signal and not the sale.Heatrail does not answerCheckoutthe order rowalready existscheck()bounded at 800 msnever throwsAny failuretimeout, non-2xx, malformed JSONdegraded true, score 0Purchase stoppedno such branchThe dashed pathdoes not exist.Queuedexact bytes, original keyDrained laterhealth probe, then replayCounted oncesame key, stored responseThe purchase is never stopped, delayed or reversed. What is missing is the signal, not the sale.
The failure path ends at the queue. It never reaches the purchase.

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.
05

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.

06

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();

Everything the SDK does, you can do directly.

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