Skip to content

Repository files navigation

Crypto Chief PHP SDK — Crypto Processing API Client

Packagist Version PHP Version CI License: MIT SDK Docs

Official PHP SDK for the Crypto Chief crypto processing API. Accept crypto payments, send single and mass payouts, sign and broadcast EVM / TRON / Solana / TON / XRP transactions, encode contract calls, manage wallets, and verify webhooks.

  • 25 chains across EVM, TRON, Solana, TON, XRP, and the BTC family
  • Single + batch payouts, auto-convert swaps, two-phase sign / execute, static deposits, pay-ins, sweeps, withdrawals, TRON energy rental, native-coin purchases, fiat ↔ crypto conversion
  • Chain and asset catalogues, fiat and quotable-ticker lists, per-wallet pay-in history, per-wallet auto-sweep policy
  • High-level helpers: ERC-20 / TRC-20 transfers, ABI-encoded EVM calls, Solana Anchor instructions, TON Jetton / NFT / text-comment transfers
  • Local RSA-OAEP / SHA-256 decryption of generated wallet private keys
  • Webhook verification + typed event parsing (framework-agnostic)
  • PSR-18 HTTP client support (Guzzle by default), strict types, readonly DTOs, backed enums

Installation

composer require crypto-chiefs/cryptochief-crypto-processing-php

Requires PHP 8.1+ with the bcmath, mbstring, openssl, and json extensions.

Quickstart

use CryptoChief\Processing\Chain;
use CryptoChief\Processing\Client;
use CryptoChief\Processing\Dto\EstimatePayoutRequest;
use CryptoChief\Processing\Dto\ExecutePayoutRequest;

$client = new Client(
    merchantId: 'YOUR_MERCHANT_ID',
    apiKey:     'YOUR_API_KEY',
);

// 1. Preview fees
$estimate = $client->payouts()->estimate(new EstimatePayoutRequest(
    network:   Chain::EthSepolia->value,
    coin:      'ETH',
    amount:    '0.0001',
    toAddress: '0xRecipient...',
));
echo "Will receive: {$estimate->amountToReceive}\n";

// 2. Execute - idempotent on orderId
$payout = $client->payouts()->execute(new ExecutePayoutRequest(
    network:     Chain::EthSepolia->value,
    coin:        'ETH',
    amount:      '0.0001',
    toAddress:   '0xRecipient...',
    orderId:     'order-1234',
    userId:      'user-42',
    urlCallback: 'https://example.com/webhook',
));

// 3. Poll until terminal (or rely on the webhook)
$final = $client->payouts()->waitFor($payout->uuid);
echo "Status: {$final->status}, tx: {$final->txid}\n";

Mass payout

use CryptoChief\Processing\Dto\BatchPayoutRequest;

$items = [];
foreach ($recipients as $i => [$to, $amount]) {
    $items[] = new ExecutePayoutRequest(
        network:     Chain::EthSepolia->value,
        coin:        'ETH',
        amount:      $amount,
        toAddress:   $to,
        orderId:     "batch-{$i}",
        userId:      "user-{$i}",
        urlCallback: 'https://example.com/webhook',
    );
}

$result = $client->payouts()->batchExecute(new BatchPayoutRequest(items: $items));
foreach ($result->items ?? [] as $row) {
    echo $row->uuid ? "OK {$row->uuid}\n" : "FAIL {$row->error}\n";
}

Funds lock sequentially inside a batch — an intra-batch double-spend cannot occur, even when the total exceeds your balance partway through. Max 50 items per call.

Accept payments (pay-ins / invoices)

A pay-in is an invoice that gives your customer a deposit address (or hosted payment page) and notifies you over webhook when it's paid. Two modes:

  • FIAT — you fix the price in fiat (amountFiat + currency); the SDK locks the crypto rate at confirmation time. The customer picks a coin/network at checkout (filter the menu with assets).
  • CRYPTO — you fix the crypto amount and the asset up front (amountCrypto + asset).

FIAT invoice ($25 USD, customer picks USDT on any supported network)

use CryptoChief\Processing\Client;
use CryptoChief\Processing\Dto\Asset;
use CryptoChief\Processing\Dto\AssetsPolicy;
use CryptoChief\Processing\Dto\CreatePayInRequest;

$invoice = $client->payIns()->create(new CreatePayInRequest(
    orderId:      'order-' . bin2hex(random_bytes(6)),
    userId:       'customer-42',
    mode:         'fiat',
    amountFiat:   '25.00',
    currency:     'USD',
    lifetimeSec:  3600,           // expires after 1 hour
    urlCallback:  'https://example.com/cryptochief/webhook',
    urlSuccess:   'https://example.com/thanks',
    urlError:     'https://example.com/oops',
    assets: new AssetsPolicy(
        allow: [
            new Asset(coin: 'USDT'),  // any network
        ],
    ),
));

echo "Invoice: {$invoice->uuid}\n";
echo "Payment link: {$invoice->paymentLink}\n";

The customer opens paymentLink and picks a coin. Once they do, the invoice transitions out of waiting_asset_select and exposes toAddress + paymentCoin + paymentNetwork.

CRYPTO invoice (exact 0.01 ETH on Sepolia)

use CryptoChief\Processing\Chain;
use CryptoChief\Processing\Dto\Asset;

$invoice = $client->payIns()->create(new CreatePayInRequest(
    orderId:      'order-' . bin2hex(random_bytes(6)),
    userId:       'customer-42',
    mode:         'crypto',
    amountCrypto: '0.01',
    asset: new Asset(
        network: Chain::EthSepolia->value,
        coin:    'ETH',
    ),
    lifetimeSec: 1800,
    urlCallback: 'https://example.com/cryptochief/webhook',
));

echo "Send {$invoice->amountCrypto} {$invoice->paymentCoin} to {$invoice->toAddress}\n";

Lifecycle

use CryptoChief\Processing\Dto\SelectAssetRequest;

// H2H integrations: commit the asset choice server-side.
$client->payIns()->selectAsset(new SelectAssetRequest(
    uuid:    $invoice->uuid,
    coin:    'USDT',
    network: Chain::TronMainnet->value,
));

// Poll until terminal (paid / cancel / expired) - or rely on the invoice.* webhook.
$final = $client->payIns()->waitFor($invoice->uuid, intervalSec: 5.0, timeoutSec: 1800.0);
echo "Status: {$final->status}\n";

// Cancel an open order before it's paid.
$client->payIns()->cancel($invoice->uuid);

Contract calls

The SDK ABI-encodes calldata for you. No more 0xa9059cbb... by hand.

use CryptoChief\Processing\Amount;
use CryptoChief\Processing\Dto\Erc20TransferRequest;

// ERC-20 / TRC-20 one-liner
$signed = $client->transactions()->erc20Transfer(new Erc20TransferRequest(
    network:       Chain::TronMainnet->value,
    fromAddress:   'TYourWallet...',
    tokenContract: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // USDT on TRON
    recipient:     'TRecipient...',
    amount:        Amount::humanToBase('1.23', 6),
));

Arbitrary Solidity calls — the SDK reads the signature, computes the Keccak-256 selector, encodes head + tail, and hands you the bytes:

This snippet shows the encoder, not a complete swap. Uniswap's router moves your input token with transferFrom, so it needs an ERC-20 approve(address,uint256) on that token first, confirmed before the swap is signed — without it the swap reverts and burns the gas. And an amountOutMin of 0 accepts whatever the pool returns, which on a public mempool hands the trade to the first sandwich bot that sees it. The runnable version, with both, is in examples/.

use CryptoChief\Processing\Dto\EvmCallRequest;

$client->transactions()->signEvmCall(new EvmCallRequest(
    network:     Chain::EthMainnet->value,
    fromAddress: '0xMerchantWallet',
    contract:    '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D', // Uniswap V2
    method:      'swapExactTokensForTokens(uint256,uint256,address[],address,uint256)',
    args:        [$amountIn, $minOut, [$dai, $weth], $to, $deadline],
));

Solana Anchor programs — Borsh has no on-wire type tags, so the SDK forces explicit typing through Borsh::* constructors:

use CryptoChief\Processing\Contract\Borsh;
use CryptoChief\Processing\Dto\AnchorCallRequest;
use CryptoChief\Processing\Dto\SolanaAccount;

$client->transactions()->signAnchorCall(new AnchorCallRequest(
    network:     Chain::SolanaMainnet->value,
    fromAddress: 'YourMerchantOwnedSolanaWallet',
    program:     'YourAnchorProgramId',
    method:      'initialize',
    args: [
        Borsh::u64(1_000_000),
        Borsh::string('hello'),
    ],
    accounts: [
        new SolanaAccount(pubkey: $from, isSigner: true, isWritable: true),
    ],
));

TON — Jetton / NFT / text comment

High-level helpers build the standard TEP-74 / TEP-62 / text-comment bodies. The underlying BoC encoding is delegated to olifanton/interop. For arbitrary contracts use signTonCall(TonCallRequest) with raw BoC bytes.

use CryptoChief\Processing\Amount;
use CryptoChief\Processing\Chain;
use CryptoChief\Processing\Dto\JettonTransferRequest;
use CryptoChief\Processing\Dto\NftTransferRequest;
use CryptoChief\Processing\Dto\TonCommentRequest;

// USDT on TON — auto-resolves the sender's jetton wallet, picks gas (0.07 or 0.15 TON).
$client->transactions()->jettonTransfer(new JettonTransferRequest(
    network:      Chain::TonMainnet->value,
    fromAddress:  'EQYourTonWallet...',
    recipient:    'EQRecipientMainWallet...',
    amount:       Amount::humanToBase('1.5', 6),   // 1.5 USDT
    jettonMaster: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', // USDT jetton master
    memo:         'invoice #1234',                  // shown by every wallet
));

// NFT transfer (TEP-62)
$client->transactions()->nftTransfer(new NftTransferRequest(
    network:     Chain::TonMainnet->value,
    fromAddress: 'EQYourTonWallet...',
    nftItem:     'EQNftItemAddress...',
    newOwner:    'EQNewOwnerAddress...',
));

// Send TON with a text comment
$client->transactions()->sendTonComment(new TonCommentRequest(
    network:     Chain::TonMainnet->value,
    fromAddress: 'EQYourTonWallet...',
    recipient:   'EQRecipient...',
    text:        'thanks!',
    amountTon:   Amount::nanoTon('0.5'),
));

For a pre-built BoC body (custom contracts), use signTonCall(TonCallRequest) directly with raw bytes.

Wallets

use CryptoChief\Processing\ChainFamily;
use CryptoChief\Processing\Dto\GenerateWalletRequest;

$client = new Client(
    merchantId:    'M',
    apiKey:        'K',
    rsaPrivateKey: '/path/to/private.pem',   // PEM string or path
);

$wallet = $client->wallets()->generate(new GenerateWalletRequest(
    walletType:  'transit',
    chainFamily: ChainFamily::Evm->value,
    label:       'hot wallet EU',   // optional, any wallet type, max 255 chars
));

if ($wallet->privateKeyEncrypted !== null) {
    // Decryption is local - the plaintext private key never leaves the process.
    $key = $client->wallets()->decryptPrivateKey($wallet->privateKeyEncrypted);
}

Nothing named at creation is fixed there — the name, the deposit webhook and the master a wallet settles to can all be changed afterwards:

// Rename a wallet, or take its name off. Any wallet type, max 255 chars.
$w = $client->wallets()->setLabel($address, 'customer 4242');
// $w->label is the name now stored - null once cleared, never ''.
$client->wallets()->clearLabel($address);                // same as passing ''

// Move the next sweep to a different master. No money moves: sweeps already queued
// land on the new master, anything already swept stays on the old one.
$w = $client->wallets()->rebindMaster($depositAddress, $newMasterAddress);
// $w->masterWalletAddress is the master the next sweep will settle to.

// Point a static wallet's deposit webhook somewhere else, or drop it.
$client->wallets()->setCallbackUrl($depositAddress, 'https://example.com/hook');
$client->wallets()->clearCallbackUrl($depositAddress);   // same as passing ''

Every response that describes a wallet — generate, info, the list, and the three calls above — carries label. label, masterWalletAddress and callbackUrl come back as null when the wallet has no such value: an unnamed wallet reads as null and never as an empty string, a master has no master of its own, a transit wallet never has a callback.

A deposit address can serve several orders over its lifetime. payInHistory() lists them — the same order records payIns()->history() returns, narrowed to one wallet:

use CryptoChief\Processing\Dto\WalletPayInHistoryQuery;

$page = $client->wallets()->payInHistory($depositAddress, new WalletPayInHistoryQuery(
    dateFrom: '2026-01-01T00:00:00+00:00',
    pageSize: 100,          // max 100, default 20
));
foreach ($page->items ?? [] as $order) {
    echo "{$order->orderId} {$order->status} {$order->amountCrypto} {$order->paymentCoin}\n";
}

The address is matched case-insensitively, so either spelling of an EVM address works, and only your project's orders come back — an address you do not own yields an empty page rather than an error.

Assets and chains

Two different questions, two endpoints. What the platform's scanner is connected to, and what you can be paid in:

// Every chain the scanner reads right now. A bare array, not an items envelope.
foreach ($client->blockchain()->blockchains() as $chain) {
    echo "{$chain->name} ({$chain->type})\n";   // ETH_MAINNET (evm)
}

// Every asset the platform supports anywhere - the "what could we turn on" list.
$catalogue = $client->blockchain()->contractsList();

// What THIS project can actually be paid in - the list that governs orders,
// sweeps and payouts.
$mine = $client->blockchain()->contractsAvailable(Chain::TronMainnet->value);

foreach ($catalogue->items ?? [] as $asset) {
    // contract is "" on a native coin, never null; isTest marks a test network.
    echo "{$asset->coin} on {$asset->network} [{$asset->chainFamily}]"
       . ($asset->isTest ? ' (testnet)' : '')
       . " decimals={$asset->decimals}\n";
}

decimals is what Amount::humanToBase() / Amount::baseToHuman() need. Note that SupportedBlockchain::$type is the scanner's lower-case protocol family (evm, tron) while an asset's chainFamily is the upper-case ChainFamily value (EVM, TRON) — the two do not compare directly.

Currencies and rates

What the platform can put a price on — the fiat codes and the crypto tickers:

// Every fiat you can price an order in. A bare array, not an items envelope.
foreach ($client->currencies()->fiats() as $fiat) {
    echo "{$fiat->code}{$fiat->name}\n";      // SEK — Swedish Krona
}

// Every ticker the platform has a rate for, and which exchange carries it.
$c = $client->currencies()->cryptos();
echo "{$c->count} tickers quoted against {$c->quote}\n";   // 2529 tickers ... USDT
foreach ($c->byExchange as $exchange => $tickers) {
    echo "{$exchange}: " . count($tickers) . "\n";         // binance, bybit, exmo, kucoin
}

All three of these — blockchains(), fiats() and cryptos() — send an empty result as JSON null rather than []. The SDK reads that as empty: blockchains() and fiats() return [], and cryptos() returns a CryptoCurrencies whose tickers and byExchange are empty arrays, nested nulls included. Nothing is thrown and nothing needs a null guard before a foreach.

cryptos() is rate availability, not payment availability. A ticker listed there is one the platform can quote a price for; it says nothing about whether your project can take a deposit, sweep or payout in it. That catalogue is contractsAvailable() above — build an asset picker from cryptos() and it offers customers assets orders will refuse. The tell is in the shape: a ticker there carries no network, no contract and no decimals, and an order needs all three.

The keys of byExchange are the values ConvertRequest::$provider accepts, which is what picks the venue a quote comes from:

use CryptoChief\Processing\Dto\ConvertRequest;

$quote = $client->currencies()->fiatToCrypto(new ConvertRequest(
    fromTicker: 'EUR',          // NOT `from:` — the property is `fromTicker`
    to:         'BTC',
    amount:     '100',
    provider:   'binance',      // omit to let the platform choose
));

The source field is fromTicker, not from: from is what goes on the wire, and the DTO renames it there. fromTicker, to and amount are all required; only provider is optional.

Webhooks

use CryptoChief\Processing\Exception\WebhookVerificationException;
use CryptoChief\Processing\Webhook;
use CryptoChief\Processing\Webhook\PayoutEvent;

$raw = file_get_contents('php://input') ?: '';   // raw bytes - never re-encode

try {
    $event = Webhook::parseEvent($apiKey, $raw, Webhook::headersFromGlobals());
} catch (WebhookVerificationException) {
    http_response_code(401);
    return;
}

if ($event instanceof PayoutEvent) {
    // typed access: $event->uuid, $event->status, $event->amountToReceive, ...
}

Laravel / Symfony: Webhook::parseEvent($apiKey, $request->getContent(), $request->headers->all()). PSR-7: Webhook::parseEvent($apiKey, (string) $request->getBody(), $request->getHeaders()). Optionally restrict by source IP: Webhook::SENDER_IPS lists the production webhook IP addresses.

Header Value
X-Webhook-Delivery (Webhook::DELIVERY_HEADER) delivery id, 1-128 characters [A-Za-z0-9_-]; the same on every attempt and resend
X-CC-Timestamp (Webhook::TIMESTAMP_HEADER) Unix time of the attempt, seconds
X-CC-Signature (Webhook::SIGNATURE_HEADER) v1=<64 hex>
string_to_sign = "CC-HMAC-SHA256-WEBHOOK-V1\n" . timestamp . "\n" . delivery_id . "\n" . hex(sha256(raw_body))
X-CC-Signature = "v1=" . hex(hmac_sha256(key = api_key, message = string_to_sign))

Webhook::verify($apiKey, $rawBody, $headers, $tolerance = 300, $now = null) returns nothing on success and otherwise throws a WebhookVerificationException subclass:

Exception Reason
WebhookHeadersException a signature header is missing, repeated or malformed
WebhookTimestampException X-CC-Timestamp is more than $tolerance seconds from $now
WebhookSignatureException the signature does not match

$headers maps header names in any case to a value or a list of values; Webhook::headersFromGlobals() builds it from getallheaders() when the SAPI provides it, otherwise from $_SERVER. Headers passed to PHP as CGI variables ($_SERVER, FPM, CGI, and Symfony / Laravel $request->headers) do not distinguish _ from - in a name: X_CC_Timestamp arrives as X-CC-Timestamp. Values are trimmed of spaces and tabs only. The signature is compared in constant time, hex in any case. $tolerance <= 0 means 300; $now is Unix seconds, the current time when null. An empty API key throws CryptoChiefException. Webhook::parseEvent() takes the same arguments, verifies, and throws CryptoChiefException when the body is not a JSON object.

A resend arrives with the same X-Webhook-Delivery and a new timestamp; deduplicate by the delivery id. Sign::webhookV1Sign($apiKey, $timestamp, $deliveryId, $rawBody) returns the X-CC-Signature value and Sign::webhookV1StringToSign($timestamp, $deliveryId, $rawBody) the string to sign, for testing a receiver.

Errors

Every SDK error extends CryptoChiefException, so a single catch covers the library. API failures arrive as ApiException with a stable $errorCode you can branch on:

use CryptoChief\Processing\ErrorCode;
use CryptoChief\Processing\Exception\ApiException;

try {
    $client->payouts()->execute($req);
} catch (ApiException $e) {
    if ($e->errorCode === ErrorCode::InsufficientFunds->value) {
        // top up and retry
    }
}

A refusal the API decided itself carries the code in error and a sentence in msg; one relayed from an upstream service marks error as SERVICE_ERROR and puts the code in msg. A refusal with an error object carries the code in error.details.code, else in error.name, and the sentence in error.message. A body without a code gives HTTP_<status>. All resolve to $errorCode, so every ErrorCode case is directly comparable. getMessage() keeps the sentence, $raw the untouched body and $serverTime the server_time of SIGNATURE_TIMESTAMP_OUT_OF_RANGE.

Only 5xx and network failures retry; 4xx is the caller's fault and surfaces immediately. The exceptions: one resend after SIGNATURE_TIMESTAMP_OUT_OF_RANGE (see Request signing), and a 5xx whose body is an order (id + status) — a settled business outcome the energy/native services recover, so retrying it is pointless.

Credits balance & top-up

API usage is billed in credits (10 000 000 credits = 1 USD). The balance check itself is free of charge and answers even at zero or negative balance, so it is safe to poll before gas-paying operations (rate-limited to 60 req/min per project):

$credits = $client->credits()->balance();

echo "USD balance: {$credits->usdBalance}\n";   // pre-formatted, e.g. "-1.52" in postpaid debt

if (!$credits->canExecuteGasOperations) {
    // top up before /v1/transaction/execute, sweeps, service-fee payouts, ...
}

Top up in USDT or USDC (USD-pegged, max 100 000 per invoice) via a hosted payment page — QR code, network selection, live status. topup() is free of charge too:

use CryptoChief\Processing\Dto\CreditsTopupRequest;

$invoice = $client->credits()->topup(new CreditsTopupRequest(
    amount:     '250.00',
    currency:   'USDT',
    urlSuccess: 'https://example.com/billing/ok',    // optional browser redirects
    urlError:   'https://example.com/billing/fail',
));

echo "Pay at: {$invoice->paymentLink}\n";           // status starts as "pending"

Network fee estimates

transactions()->estimate() prices the network fee of a transaction WITHOUT signing or broadcasting it. estimatedFee is the fee in the native coin; required is the total native balance the sender must hold (fee + value for a native transfer, fee only for a token one — the token amount comes off the token balance). Contract calls cannot be estimated (400 CONTRACT_ESTIMATE_UNSUPPORTED).

use CryptoChief\Processing\Amount;
use CryptoChief\Processing\Chain;
use CryptoChief\Processing\Dto\EstimateTransactionRequest;

$fee = $client->transactions()->estimate(new EstimateTransactionRequest(
    network:     Chain::TronMainnet->value,
    fromAddress: 'TYourWallet...',
    toAddress:   'TRecipient...',
    value:       Amount::humanToBase('100', 6),  // 100 TRX in sun
));

echo "fee {$fee->estimatedFee} TRX, sender needs {$fee->required} TRX\n";

TRON estimates additionally carry a fee breakdown (every other family leaves these null — the keys are absent from the JSON):

  • feeExpected — what the transfer will probably cost given the energy the sender currently holds (staked / delegated / rented). NOT a guarantee: the pool can expire or be spent by another transfer before the broadcast, so fund estimatedFee, not this.
  • feeLimit — the on-chain fee cap written into the transaction.
  • energy — energy units the transaction needs.
  • energyFee, bandwidthFee, activationFee — the gross burn with an empty pool; the three sum to estimatedFee. activationFee is set only for a native transfer to an address the chain has not seen yet.

TRON energy rental

Renting the energy a TRON transfer needs is cheaper than burning TRX for it. Orders are charged to the same credits balance as the rest of the API. quote() and order() are free of charge; rent() is the paid call.

use CryptoChief\Processing\Dto\EnergyQuoteRequest;
use CryptoChief\Processing\Dto\EnergyRentRequest;

// 1. Price it - free of charge. receiveAddress is the SENDER of the transfer:
//    the address the energy is delegated to.
$quote = $client->energy()->quote(new EnergyQuoteRequest(
    receiveAddress: 'TYourSenderWallet...',
));
echo "{$quote->priceTrx} TRX (~\${$quote->priceUsd}), saves {$quote->savingTrx} TRX vs burning\n";

// 2. Rent, synchronously: by the time rent() answers, the energy is delegated or the
//    reason it could not be is known. The Idempotency-Key is REQUIRED - it is what
//    makes a retry safe.
$order = $client->energy()->rent(new EnergyRentRequest(
    quoteRef: $quote->ref,          // buy at the held price; or pass receiveAddress/energy directly
), 'energy-' . bin2hex(random_bytes(6)));

// 3. Branch on the outcome - the answer is always an order:
if ($order->needsAttention) {
    // 409 unresolved: the supplier never answered, the energy MAY be delegated.
    // Do NOT retry - follow it with $client->energy()->order($order->idempotencyKey).
} elseif ($order->status === 'refused') {
    // 502 - or 402 when $order->errorCode is INSUFFICIENT_CREDITS (top the credits up).
    // Nothing was charged: $order->credits and $order->priceUsd are null, $order->error
    // says why. Retrying with a NEW idempotency key is safe.
} else {
    // delivered: $order->deliveredEnergy units are delegated for $order->durationSec.
}

Errors with no order to report (409 QUOTE_EXPIRED, 409 NOT_WORTH_RENTING, ...) arrive as a regular ApiException.

Native-coin purchases

The platform sells the native coin (TRX, ETH, BNB, SOL, TON, ...) out of its own liquidity, to any address you name — the platform pays for the transfer, and its fee is already in the price. The price covers the coins at the current market rate plus the platform's transfer fee: totalUsd is the full price and credits the exact amount charged to the same credits balance as the rest of the API. quote() and order() are free of charge; buy() is the paid call.

use CryptoChief\Processing\Dto\NativeQuoteRequest;
use CryptoChief\Processing\Dto\NativeBuyRequest;

// 1. Price it - free of charge. The quote holds the price for ~90 seconds and is
//    single-use.
$quote = $client->native()->quote(new NativeQuoteRequest(
    network:        'TRON_MAINNET',             // platform network id, not the "TRX" ticker
    receiveAddress: 'TAnyAddress...',           // any address; the platform pays for the transfer
    amount:         '25.5',                     // human units
));
echo "{$quote->amount} TRX for \${$quote->totalUsd} ({$quote->credits} credits)\n";

// 2. Buy, synchronously: by the time buy() answers, the coins are sent or the reason
//    they could not be is known. The Idempotency-Key is REQUIRED - it is what makes a
//    retry safe.
$order = $client->native()->buy(new NativeBuyRequest(
    quoteRef: $quote->ref,          // buy at the held price; or pass network/receiveAddress/amount directly
), 'native-' . bin2hex(random_bytes(6)));

// 3. Branch on the outcome - the answer is always an order:
if ($order->needsAttention) {
    // 409 unresolved: the transfer's outcome never arrived, the coins MAY be sent.
    // Do NOT retry - follow it with $client->native()->order($order->idempotencyKey).
} elseif ($order->status === 'refused') {
    // 502 - or 402 when $order->errorCode is INSUFFICIENT_CREDITS (top the credits up).
    // Nothing was charged: $order->credits and $order->totalUsd are null, $order->error
    // says why. Retrying with the same key returns this same order; a NEW key re-attempts.
} else {
    // delivered: $order->txHash is the transfer.
}

Errors with no order to report (409 QUOTE_EXPIRED / QUOTE_ALREADY_USED — quote again, ...) arrive as a regular ApiException.

Amount precision

Crypto amounts are decimal strings end-to-end. float loses precision past 2^53 and binary rounding bites large token values, so the SDK never uses it for amounts. Convert between human and base units with Amount::humanToBase() / Amount::baseToHuman():

use CryptoChief\Processing\Amount;

Amount::humanToBase('1.5', 18);    // "1500000000000000000"
Amount::baseToHuman('10000', 8);   // "0.0001"
Amount::nanoTon('0.05');           // "50000000"

Configuration

$client = new Client(
    merchantId:    'M',
    apiKey:        'K',
    baseUrl:       Client::DEFAULT_BASE_URL,    // override for staging
    userAgent:     'my-app/1.0',
    retries:       3,
    timeoutSec:    60.0,
    retryBaseMs:   200.0,
    retryMaxMs:    5000.0,
    httpClient:    $myPsr18Client,              // bring your own
    rsaPrivateKey: '/path/to/private.pem',
);

httpClient accepts any Psr\Http\Client\ClientInterface. The default is Guzzle 7.

Request signing

Requests are signed with HMAC-SHA256 v1.

Header Value
Merchant merchant ID
X-CC-Timestamp Unix time, seconds
X-CC-Nonce 32 hex characters, new for every attempt
X-CC-Signature v1=<64 hex>
string_to_sign = "CC-HMAC-SHA256-REQ-V1\n" . timestamp . "\n" . nonce . "\n" . METHOD . "\n"
               . path . "\n" . query . "\n" . merchant . "\n" . idempotency_key . "\n"
               . hex(sha256(body))
X-CC-Signature = "v1=" . hex(hmac_sha256(key = api_key, message = string_to_sign))

path is the route path (/v1/payout/execute) without the base URL prefix, percent-decoded (/v1/a%20b is signed as /v1/a b). query is without ? (empty if none), exactly as sent. body is the exact bytes sent. Timestamp, nonce and signature are computed on every retry. On SIGNATURE_TIMESTAMP_OUT_OF_RANGE the client sets its clock offset from server_time once and resends the request.

use CryptoChief\Processing\Sign;

$timestamp = (string) time();
$nonce     = Sign::hmacV1Nonce();
$signature = Sign::hmacV1Sign(
    apiKey:         $apiKey,
    timestamp:      $timestamp,
    nonce:          $nonce,
    method:         'POST',
    path:           '/v1/payout/execute',
    query:          '',
    merchant:       $merchantId,
    idempotencyKey: '',
    body:           $rawBody,
);

Sign::hmacV1StringToSign() takes the same fields without apiKey. An apiKey that is empty or only spaces and tabs is refused.

$client->request() sends a signed request with any method, for a route the SDK does not model; Idempotency-Key goes on one call or on every call of a client copy, and is part of the string to sign either way.

$balance = $client->request('/v1/balance?address=' . $address, method: 'GET');

$client->request('/v1/payout/execute', $body, 'payout-2026-09-16-0001');
$client->withIdempotencyKey('payout-2026-09-16-0001')->payouts()->execute($req);

The body is the request value encoded with json_encode(), / and non-ASCII characters unescaped, and is sent and signed as is. Object members whose value is null are not sent, including in $client->request() bodies; null gives an empty body and a top-level [] gives {}. Integers are sent exactly. A float is written by json_encode() according to the serialize_precision ini setting; the default -1 gives the shortest digits that round-trip (0.1 + 0.2 is sent as 0.30000000000000004).

Documentation

SDKs for other languages live under the crypto-chiefs GitHub organization.

FAQ — common crypto-processing tasks in PHP

  • How do I accept crypto payments in PHP? Open a pay-in via $client->payIns()->create(new CreatePayInRequest(...)). The response carries the paymentLink (and the address once the customer picks a coin).

  • How do I send mass payouts in PHP? Call $client->payouts()->batchExecute(new BatchPayoutRequest(items: $items)) with up to 50 recipients. Each item idempotent on its orderId.

  • How do I send USDT (TRC-20 / ERC-20 / BEP-20) from PHP? erc20Transfer() — the SDK encodes transfer(address,uint256) and handles TRON base58 addresses transparently.

  • How do I send Jettons (USDT on TON, etc.) from PHP? jettonTransfer() — the SDK builds the TEP-74 body, auto-resolves the sender's Jetton wallet via the gateway's TON RPC proxy, and picks the gas budget.

  • How do I verify Crypto Chief webhooks in PHP? Webhook::parseEvent($apiKey, $rawBody, Webhook::headersFromGlobals()) — verifies the HMAC-SHA256 signature over the raw body and returns a typed event.

  • How do I check my API credits balance in PHP? $client->credits()->balance() — free of charge, and canExecuteGasOperations tells you up front whether gas-paying operations would pass the billing gate.

  • How do I top up my API credits from PHP? $client->credits()->topup(new CreditsTopupRequest(amount: '250.00', currency: 'USDT')) — returns a hosted paymentLink (QR code, network selection, live status). Also free of charge.

  • How do I control when a deposit wallet is swept? $client->sweeps()->settings($address) reads the policy in force and updateSettings() changes it — sweep on arrival (SweepPolicyMode::Momentum), sweep once the balance reaches an amount (SweepPolicyMode::Threshold plus thresholdAmountUsd), or never on its own (SweepPolicyMode::Off; a force sweep still works). The read comes back in three layers — what will happen, what this wallet overrides, and what it inherits from the project — so a value of your own is distinguishable from an inherited one:

    $s = $client->sweeps()->updateSettings(
        address: $depositAddress,
        typeWork: SweepPolicyMode::Threshold,
        thresholdAmountUsd: '250',
    );
    // $s->effective is the resolved policy; $s->effective->source names the layer it came from.

    Inheritance is per field: overriding the mode leaves the fee mode inherited. To stop overriding a field, pass Clear::value()null already means "leave this field alone", so it cannot also mean "reset it". fields accepts four names — type_work, threshold_amount_usd, fee_mode and gas_source — and updateSettings() fills the mask in from whichever arguments you passed.

  • Why is my TRON sweep buying energy on my API credits? Because nothing said otherwise. gasSource is rented by default — the platform supplies the energy for the transfer and bills it to your credits after it is on chain, whatever your feeMode. Not setting it is not the same as setting native. To have the wallet burn its own TRX instead, send it explicitly:

    use CryptoChief\Processing\SweepGasSource;
    
    $s = $client->sweeps()->updateSettings(
        address:   $tronDepositAddress,
        gasSource: SweepGasSource::Native,
    );
    echo $s->effective?->gasSource;   // "native" — the resolved value, always concrete

    Read effective->gasSource to see what will actually happen. A null in the override layer means only that this layer does not decide it — inherited, not switched off. TRON only; the value is carried and ignored on every other chain. Clear::value() drops the override and goes back to inheriting.

  • How do I find a sweep by transaction hash? search on SweepHistoryQuery — a substring match on the wallet address, the sweep or gas-pump transaction hash, and the task_id (the wallet variant matches the hashes and task_id, its address already being fixed). status narrows to one sweep status; leave it out and every status comes back, SweepStatus::Skipped among them, which is a balance below the wallet's threshold and a normal outcome rather than a failure.

    $client->sweeps()->history(new SweepHistoryQuery(
        status: SweepStatus::Failed->value,
        search: '0x6269770518fed4...',
    ));
  • How do I list every payment made to one deposit address? $client->wallets()->payInHistory($address) — the same order records as payIns()->history(), narrowed to that wallet, with an optional date window and paging. Useful when a payer says they sent funds and you have the address but not the order.

  • Which chains and assets can I use? $client->blockchain()->blockchains() lists the chains the platform's scanner is connected to (a bare array of name + protocol family). contractsList() is the platform-wide asset catalogue — every coin and token on every network, each with chainFamily, isTest and decimals, and an empty contract on a native coin. contractsAvailable() is the narrower list your project can actually be paid in, and the one that governs orders, sweeps and payouts.

  • Which fiat currencies can I price an order in? $client->currencies()->fiats() — the ISO 4217 codes a FIAT-mode pay-in's currency and the fiat side of a rate quote accept, each with a display name to render. A bare array, not an items envelope.

  • Which crypto assets can the platform quote a price for? $client->currencies()->cryptos() — every ticker with a rate against USDT, plus byExchange telling you which venue carries which (those keys are what ConvertRequest::$provider takes). Rate availability only: a ticker there is not an asset your project can be paid in — contractsAvailable() is that list, and a picker built from cryptos() offers assets orders will refuse.

  • How do I name a wallet in PHP? Pass label to GenerateWalletRequest — it works for master, transit and static wallets alike, holds up to 255 characters, and is for your own bookkeeping: the platform stores and echoes it, it routes nothing. Leave it unset and it stays off the wire.

  • How do I rename a wallet after creating it? $client->wallets()->setLabel($address, 'customer 4242') — every wallet type, not just static ones. An empty string is a value, not an omission: it clears the name and the SDK sends it as "" rather than dropping the field, which clearLabel($address) spells out. Over 255 characters the call fails with LABEL_TOO_LONG (ErrorCode::LabelTooLong). Read the name back from label on any wallet response — it is null when the wallet has no name, never '', so label === null is the one test for "unnamed".

  • How do I move a deposit wallet to another master wallet? $client->wallets()->rebindMaster($address, $newMasterAddress). It moves no money — it changes where the next sweep settles, including sweeps already queued but not yet sent. Anything already swept stays on the previous master; move that with a payout. The call is idempotent, master wallets cannot be re-pointed, and the new master has to be on the same project and chain family and not frozen.

  • How do I change a static wallet's deposit webhook after creating it? $client->wallets()->setCallbackUrl($address, $url) — static wallets only (master and transit answer 400). An empty string is a value, not an omission: it clears the webhook and the SDK sends it as "" rather than dropping the field, which clearCallbackUrl($address) spells out. The new URL applies to deposits announced from here on; one already announced is not re-announced to it.

  • How do I know a sweep actually settled? $sweep->isFinal(): status is completed and sweepConfirmations >= requiredConfirmations; without requiredConfirmations, completed and sweepConfirmations above zero. Or the sweep.confirmed webhook arrived. The sweep reads broadcasted while sweepConfirmations grows, so a count above zero alone is not settlement.

    A completed row with sweepConfirmations 0 was never observed on chain; isFinal() is false for it.

    Not completedAt. It is set at broadcast, and also on waiting_gas, failed and skipped. Take the settlement moment from confirmedAt on the sweep.confirmed webhook.

  • How many confirmations does a payout have? Each sources[] entry carries confirmations, and so does each raw serviceOperations[] item. The payout's own confirmations is the lowest among its sources, and requiredConfirmations is the network's finality depth. All four are optional. The payout stays confirm_check until every source reaches requiredConfirmations, then turns paid. waitFor() waits up to 5400 seconds by default; pass a larger timeoutSec for slower networks.

  • How many confirmations does a sign / execute transaction have? confirmations and requiredConfirmations are always present. confirmations is 0 until the transaction is in a block, then grows while the status is broadcasted; at requiredConfirmations the transaction turns confirmed. The transaction.* webhook is sent only on final statuses, so read the growing count with info().

  • How do I know a manual withdrawal went through? Withdrawals are started from the merchant cabinet and have no webhook; read them with $client->withdrawals()->info($uuid) or history(). A withdrawal reads confirm_check until confirmations reaches requiredConfirmations, then completed:

    $w = $client->withdrawals()->info($uuid);
    if ($w->status === 'completed') {
        // final; $w->completedAt is the moment, $w->actualFeeFiat the fee in USD
    } elseif ($w->status === 'failed') {
        // final, nothing settled; $w->errorReason says why
    } else {
        printf("%s: %s/%d confirmations\n", $w->status, $w->confirmations ?? '-', $w->requiredConfirmations ?? 0);
    }

    Statuses: queue, refueling, refuel_confirmed, broadcasting, sending, in_mempool, confirm_check, and the final completed and failed. confirmations is optional; requiredConfirmations is always sent. error, confirmedAt, contract, amountFiat and updatedAt are never sent and are deprecated.

  • Who pays the gas for a sweep, and does it cost me credits? A deposit wallet holding enough of the chain's native coin pays for its own transfer whatever SweepFeeMode says; the mode only decides who covers a shortfall. Client takes it from your own master wallet. Service has the platform supply it and bills the cost to your API credits. Mix — the default — tries Client and falls back to Service when the master wallet cannot cover it, so a master wallet running dry moves the gas onto your credits rather than stopping the sweep.

  • How do I keep test payments off real chains? Set environment on CreatePayInRequest to Environment::Testnet->value or Environment::Mainnet->value. It constrains the asset the platform picks when you have not named a concrete network — fiat mode and ANY — so an unconstrained pick cannot put a real payment on a test chain. Omit it to use the project's default.

  • Does it work with Laravel / Symfony? Yes — the HTTP client is PSR-18 compatible and the webhook verifier takes raw bytes, so it slots into any framework's request body.

License

MIT — see LICENSE.

About

Official PHP SDK for the Crypto Chief crypto processing API. Accept crypto payments, send single and mass payouts (USDT, USDC, ETH, BTC, TON, SOL), sign EVM/TRON/Solana/TON/XRP transactions, encode contract calls, verify webhooks. PHP 8.1+, Composer, PSR-18.

Topics

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages