Skip to content

Recipes

Pim Feltkamp edited this page Apr 26, 2026 · 1 revision

Recipes

Practical, copyable patterns for cryptohopper/sdk (Composer). Every snippet runs as-is — paste into a PHP file and execute with php file.php. They use only the public SDK surface, never internals.

The SDK is synchronous and built on Guzzle. Responses are decoded JSON arrays — access fields with ['key'], not arrow notation.

composer require cryptohopper/sdk

Contents

Build the client and make a single call

<?php
require __DIR__ . '/vendor/autoload.php';

use Cryptohopper\Sdk\Client;

$ch = new Client(apiKey: getenv('CRYPTOHOPPER_TOKEN'));

$me = $ch->user->get();
echo $me['email'], PHP_EOL;

The SDK uses PHP 8.1+ named arguments throughout — new Client(apiKey: ..., timeout: 8, maxRetries: 1) is the idiomatic call style.

Wait for a backtest to finish

Backtests run async server-side. create returns immediately with an ID; you poll get until status is terminal.

function runBacktest(Client $ch, int|string $hopperId, string $fromDate, string $toDate): array {
    $bt = $ch->backtest->create([
        'hopper_id'  => $hopperId,
        'start_date' => $fromDate,
        'end_date'   => $toDate,
    ]);

    while (true) {
        $cur = $ch->backtest->get($bt['id']);
        if (in_array($cur['status'] ?? '', ['completed', 'failed'], true)) {
            return $cur;
        }
        sleep(5);
    }
}

The backtest rate bucket is separate (1 request per 2 seconds). 5-second polling stays well clear.

Find every open position across all your hoppers

foreach ($ch->hoppers->list() as $h) {
    foreach ($ch->hoppers->positions($h['id']) as $p) {
        printf(
            "%s (#%s): %s %s @ %s\n",
            $h['name'] ?? '?', $h['id'], $p['amount'] ?? '?', $p['coin'] ?? '?', $p['rate'] ?? '?',
        );
    }
}

This is sequential — one request per hopper. PHP doesn't have built-in async; if you need parallel fan-out, see Guzzle's Pool (next-but-one recipe) for true concurrency.

Detect new fills since the last poll

$seen = [];

while (true) {
    foreach ($ch->hoppers->orders($hopperId) as $o) {
        $id = $o['id'] ?? null;
        if ($id === null || isset($seen[$id])) {
            continue;
        }
        if (($o['status'] ?? '') === 'filled') {
            $seen[$id] = true;
            printf("Fill: %s %s %s @ %s\n", $o['market'], $o['type'], $o['amount'], $o['price']);
        }
    }
    sleep(10);
}

For production-grade fill notifications, configure the webhooks resource — push beats poll for event delivery.

Match on CryptohopperException codes (PHP 8 match)

getCode() is shadowed by PHP's Exception::getCode() (returns int). The string code lives on getErrorCode().

use Cryptohopper\Sdk\Exceptions\CryptohopperException;

try {
    $ch->hoppers->get('999999999');
} catch (CryptohopperException $e) {
    match ($e->getErrorCode()) {
        'NOT_FOUND'                  => print("no such hopper\n"),
        'UNAUTHORIZED', 'FORBIDDEN'  => printf("auth problem; IP we sent: %s\n", $e->getIpAddress() ?? 'unknown'),
        'RATE_LIMITED'               => printf("rate limited; retry after %dms\n", $e->getRetryAfterMs() ?? 0),
        default                      => throw $e,
    };
}

$e->getErrorCode() is a stable string — compare with ===, never substring-match.

Fail fast on auth errors, retry on transient ones

The SDK auto-retries 429s. For 5xx and network errors you may want a tighter retry. Auth errors should never be retried.

function withRetry(callable $fn, int $maxAttempts = 3): mixed {
    $attempt = 0;
    while (true) {
        $attempt++;
        try {
            return $fn();
        } catch (CryptohopperException $e) {
            if (in_array($e->getErrorCode(), ['UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND', 'VALIDATION_ERROR'], true)) {
                throw $e;
            }
            if ($attempt >= $maxAttempts) {
                throw $e;
            }
            usleep(500_000 * (2 ** ($attempt - 1)));
        }
    }
}

$me = withRetry(fn () => $ch->user->get());

Read your remaining backtest quota

$limits = $ch->backtest->limits();
printf("Backtests remaining: %s of %s\n", $limits['remaining'] ?? '?', $limits['limit'] ?? '?');

For the normal and order buckets there's no explicit quota endpoint — the only signal is Retry-After on a 429 (read it via $e->getRetryAfterMs()).

Bring your own Guzzle client (proxies, middleware, mTLS)

Every option you can pass to GuzzleHttp\Client::__construct is yours.

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Log\LoggerInterface;
use Cryptohopper\Sdk\Client;

$stack = HandlerStack::create();
$stack->push(Middleware::log($logger, new \GuzzleHttp\MessageFormatter('{method} {uri} -> {code} ({phrase})')));

$guzzle = new GuzzleClient([
    'handler'  => $stack,
    'proxy'    => 'http://corp-proxy:8080',
    'verify'   => '/etc/ssl/certs/corp-ca.pem',
]);

$ch = new Client(
    apiKey: getenv('CRYPTOHOPPER_TOKEN'),
    httpClient: $guzzle,
);

When you pass httpClient, the SDK uses it as-is — timeout and base URL still apply (set per-request). Don't set conflicting global options on your Guzzle client.

Tighten timeouts for short-lived workers

Default timeout is 30 seconds. Inside an AWS Lambda PHP runtime (15s) or any short-lived worker, drop it.

$ch = new Client(
    apiKey: getenv('CRYPTOHOPPER_TOKEN'),
    timeout: 8,        // ~half your function budget
    maxRetries: 1,     // leave room for one retry inside the function lifetime
);

A CryptohopperException with getErrorCode() === 'TIMEOUT' is much easier to handle than a process kill.

Disable the SDK's built-in retry and handle 429 yourself

$ch = new Client(
    apiKey: getenv('CRYPTOHOPPER_TOKEN'),
    maxRetries: 0,
);

try {
    $ch->hoppers->list();
} catch (CryptohopperException $e) {
    if ($e->getErrorCode() === 'RATE_LIMITED') {
        printf("rate limited; server says wait %dms\n", $e->getRetryAfterMs() ?? 0);
        // your custom queue / circuit breaker / etc.
    } else {
        throw $e;
    }
}

Useful when you have your own queue, want exact backoff control, or are running inside something that already does retries (Laravel queues, Symfony Messenger).

Mock the SDK in PHPUnit tests with a Guzzle MockHandler

The SDK accepts any Psr\Http\Client\ClientInterface. Plug in Guzzle's MockHandler.

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Cryptohopper\Sdk\Client;

class UserTest extends TestCase
{
    public function test_user_get(): void
    {
        $mock  = new MockHandler([
            new Response(200, [], json_encode(['data' => ['id' => 42, 'email' => 'alice@example.com']])),
        ]);
        $stack = HandlerStack::create($mock);
        $http  = new GuzzleClient(['handler' => $stack]);

        $ch    = new Client(apiKey: 'test', httpClient: $http);

        $me    = $ch->user->get();
        self::assertSame(42, $me['id']);
    }

    public function test_retry_on_429(): void
    {
        $mock  = new MockHandler([
            new Response(429, ['Retry-After' => '0']),
            new Response(200, [], json_encode(['data' => ['id' => 42]])),
        ]);
        $http  = new GuzzleClient(['handler' => HandlerStack::create($mock)]);

        $ch    = new Client(apiKey: 'test', httpClient: $http);
        self::assertSame(42, $ch->user->get()['id']);
    }
}

The SDK pulls data out of the envelope automatically — your mock returns {"data": ...}, your assertion sees the inner value.

See also