cryptolike / sdk
Official server-side SDK for the CryptoLike merchant API: signed requests, automatic Idempotency-Key, retries with backoff, webhook signature verification. PHP 7.4-8.x, no runtime dependencies, works without Composer (autoload.php).
Requires
- php: >=7.4
- ext-hash: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^9.6 || ^10.5
Suggests
- ext-curl: The default transport; without it the SDK uses the http stream wrapper (allow_url_fopen + ext-openssl)
- psr/http-client: Pass any PSR-18 client (Guzzle, Symfony HttpClient) instead of the built-in cURL transport
- psr/http-factory: Required together with psr/http-client
Provides
None
Conflicts
None
Replaces
None
README
Minimal server-side SDK for the CryptoLike merchant API. PHP 7.4 – 8.x, no runtime
dependencies, works with or without Composer. HTTP through ext-curl; without it, through PHP's
https:// stream wrapper (allow_url_fopen + ext-openssl); or any PSR-18 client.
composer require cryptolike/sdk
- Signed requests (
X-API-Key/X-Timestamp/X-Signature, HMAC-SHA256 — Authentication) Idempotency-Keygenerated for everyPOST(pass your own to make retries exact)- Retries with exponential backoff and jitter on
429(honoursRetry-After),5xxand transport errors - Every endpoint of the merchant API as a method; responses are arrays shaped as in the API reference; amounts are decimal strings, floats are refused
Webhook::verify()/Client::verifyWebhook()— signature and timestamp check of webhook deliveries (Webhooks)- For shop plugins:
createInvoiceForOrder()(one open invoice per order, platform metadata),Amount::format()(shop total → decimal string) — see "Shop plugins" below
Without Composer (CMS plugins)
Copy autoload.php and src/ into your plugin (for example lib/cryptolike-sdk/) and require the
one file — a PSR-4 autoloader for CryptoLike\, nothing else is loaded:
require __DIR__ . '/lib/cryptolike-sdk/autoload.php'; $api = new \CryptoLike\Client(['key_id' => $keyId, 'secret' => $secret, 'base_url' => $apiUrl]);
The tests load the SDK exactly this way. If several plugins on one site vendor the SDK, the first autoloader registered serves all of them — ship the same SDK version.
The secret stays on your server
The API secret signs requests as you: whoever has it can create payments, read balances and change your webhooks. Use this package from your backend only; never put the secret into JavaScript, a mobile app or anything served to a browser. Your page needs only the invoice id /
checkout_urlyour server obtained — see Payment widget §1.
Client reads CRYPTOLIKE_API_KEY_ID and CRYPTOLIKE_API_SECRET from the environment when the
options leave them empty; the secret is never logged and __debugInfo() hides it from
var_dump, print_r and json_encode. PHP ignores __debugInfo() in var_export() — do not
dump a client that way.
Quickstart — to the first confirmed payment
use CryptoLike\Client; use CryptoLike\ApiException; $api = new Client(); // credentials from the environment // 1. Create a payment on your server; send the customer to checkout_url. $invoice = $api->createInvoice( ['amount' => '10.50', 'currency' => 'USD', 'order_id' => 'A-1001', 'return_url' => 'https://shop.example/orders/A-1001'], 'order-A-1001', // Idempotency-Key — a retry never creates a second payment ); header('Location: ' . $invoice['checkout_url'], true, 302); // 2. Learn the outcome from the webhook (below) — or poll: $detail = $api->getInvoice($invoice['id']); if ($detail['status'] === 'paid') { fulfil($detail['order_id']); }
// 3. Webhook receiver — verify over the RAW body, dedupe by event_id, answer 2xx fast. use CryptoLike\Webhook; use CryptoLike\WebhookVerificationException; try { $event = Webhook::verify(getallheaders(), file_get_contents('php://input'), getenv('CRYPTOLIKE_WEBHOOK_SECRET')); } catch (WebhookVerificationException $e) { http_response_code(401); exit; } if (!alreadySeen($event['event_id'])) { handle($event); } // $event['type'] === 'invoice.paid' → fulfil http_response_code(200);
Laravel / Symfony: Webhook::verify($request->headers->all(), $request->getContent(), $secret).
Full examples: examples/create-payment.php, examples/webhook.php.
Methods
| Area | Methods |
|---|---|
| Invoices | createInvoice($params, $idempotencyKey), getInvoice, listInvoices($filters), iterateInvoices($filters), cancelInvoice, listInvoiceCoinOptions($id), selectInvoiceCoin($id, $coinId, $idempotencyKey) |
| Addresses | createAddress, getAddress($id, $limit, $cursor), listAddresses, iterateAddresses, archiveAddress($id), restoreAddress($id) |
| Static-address archive | archiveStaticAddress($id), restoreStaticAddress($id), listArchivedStaticAddresses($filters), iterateArchivedStaticAddresses($filters) |
| Transactions | listTransactions, iterateTransactions, getTransaction, exportTransactionsCsv |
| Catalogue | listBalances(), listCurrencies(), listRates($fiat) |
| Withdrawals | estimateWithdrawal, createWithdrawal, getWithdrawal, listWithdrawals, iterateWithdrawals, cancelWithdrawal |
| Webhooks | listWebhookEndpoints, createWebhookEndpoint, getWebhookEndpoint, updateWebhookEndpoint, deleteWebhookEndpoint, rotateWebhookSecret, listWebhookDeliveries, getWebhookDelivery, retryWebhookDelivery |
| Widget | getEmbedOrigins(), setEmbedOrigins($origins) — checkout widget allowlist |
| Any endpoint | request($method, $path, $query, $body, $idempotencyKey) |
Static-address archive
Archiving stops offering an address and takes it out of real-time scanning; it is never deleted, and its address is never issued again. The platform checks archived addresses for incoming transfers regularly (every 24 h by default): a transfer found is credited as usual and restores the address automatically (webhook static_address.restored, reason deposit). Addresses with no incoming transfers for the platform's period (180 days by default) are archived automatically after a warning 7 days earlier (static_address.archive_warning, then static_address.archived with reason auto).
$api->archiveStaticAddress($id); // POST /v1/addresses/{id}/archive — a repeat is a no-op $page = $api->listArchivedStaticAddresses(['search' => 'shop']); // status=archived; coin_id, customer_id, limit, cursor foreach ($api->iterateArchivedStaticAddresses() as $a) { echo $a['id'], ' ', $a['archive_reason'], "\n"; } $api->restoreStaticAddress($id); // ApiException 409 conflict, details['active_address_id']: archive that one first
Archive webhooks: Webhook::STATIC_ADDRESS_ARCHIVED|RESTORED|ARCHIVE_WARNING; data — address, reason, and deposit / conflict_with (restored) or archive_after / inactive_since (warning). Full example: examples/static-address-archive.php, examples/webhook.php.
List filters are query parameters named as in the API (['status' => 'paid', 'from' => '…', 'limit' => 50]);
lists return ['items' => …, 'next_cursor' => …]; iterate*() walks every page.
Payer chooses the coin
An invoice in a fiat currency (or a ticker on several networks) without coin_id lets the payer
choose the coin on the payment page; each option shows an estimate (≈) — the exact amount and
rate are fixed when the payer picks. allowed_coin_ids narrows the choice for one invoice; leave
it out to offer every coin you offer for invoices:
$ids = []; foreach ($api->listCurrencies() as $coin) { // signed: each coin carries invoice_enabled if (($coin['invoice_enabled'] ?? true) && !$coin['maintenance'] && $coin['ticker'] === 'USDT') { $ids[] = $coin['id']; } } $invoice = $api->createInvoice(['amount' => '49.90', 'currency' => 'USD', 'allowed_coin_ids' => $ids]); // $invoice['allowed_coin_ids'] — the kept set; null = every coin offered for invoices.
The server keeps the ids you offer for invoices («Available for invoices» in the cabinet → Coins)
that match currency, drops the rest; none left → ApiException 422 no_allowed_coins
(field() === 'allowed_coin_ids'). Not combinable with coin_id (400). An empty list throws
InvalidArgumentException before the request — a filter that found nothing never turns into
"every coin". createInvoiceForOrder() takes the same list in $options['allowed_coin_ids']
(the plugins' "Allowed coins" setting; pass it only when the setting names coins).
Your own payment page (host-to-host)
Instead of sending the payer to checkout_url you can show the payment inside your own checkout
(Host-to-host guide). Every call below runs on your server;
your page receives only what it renders.
// 1. An invoice without coin_id — the payer picks the coin on your page. $inv = $api->createInvoice(['amount' => '49.90', 'currency' => 'USD', 'order_id' => 'A-1001'], 'order-A-1001'); // 2. The coins to offer, each with an informative ≈ amount (null + estimate_reason when it cannot be priced now). foreach ($api->listInvoiceCoinOptions($inv['id']) as $o) { $options[] = ['id' => $o['coin_id'], 'label' => "{$o['ticker']} · {$o['network_name']}", 'approx' => $o['estimated_amount']]; } // 3. The payer's choice fixes the coin, the address and — for a fiat amount — the rate. // One key per (invoice, coin): a retry replays the first answer instead of choosing twice. $pay = $api->selectInvoiceCoin($inv['id'], $chosenCoinId, "select-{$inv['id']}-{$chosenCoinId}"); // 4. The payment block of your page. $block = [ 'network' => $pay['network'], // name the network next to the address — a transfer on another network is lost 'address' => $pay['address'], // with a copy button 'amount' => "{$pay['payer_amount']} {$pay['ticker']}", // exactly this amount (a decimal string), with a copy button 'qr' => $pay['qr_payload'], // the bare address on every network (exchanges refuse a URI) — keep the amount next to it 'wallet_link' => $pay['payment_uri'], // "Open in wallet" 'expires_at' => $pay['ttl_expires_at'], // countdown ]; // 5. Progress for the page: poll from your server (the browser never talks to the API). $now = $api->getInvoice($inv['id']); $progress = [$now['status'], $now['confirmations'], $now['required_confirmations'], $now['remaining_amount'], $now['payment_uri']];
- Fulfil the order on the
invoice.paidwebhook only (verified as below), never on what the page shows.underpaid— the payer may top up to the same address untilttl_expires_at: showremaining_amount,payment_urialready asks for the remainder.overpaid_amountabove zero — credited, settle the difference with the customer.late_payment— a transfer after the invoice closed was credited; decide whether to fulfil or refund. qr_payloadis the bare address on every network (never a scheme or an amount): many payers send from an exchange, and its withdrawal form accepts an address only. To offer a "QR with amount" for wallets (BIP-21, Bitcoin/Litecoin), encodepayment_urion the payer's choice.payment_uri,qr_payload,confirmationsandrequired_confirmationsarenulluntil the coin is chosen;confirmationsstaysnullwhile nothing was seen on the chain. Webhook payloads do not carry them — read the invoice.selectInvoiceCoinrefusals:409 conflict— the coin is already chosen (also with the same coin) or the invoice is no longernew: read the invoice;400withfield() === 'coin_id'— the coin is not amonglistInvoiceCoinOptions;503— the rate or an address is not available right now: the SDK retries it with the same key.listInvoiceCoinOptionson an invoice with a coin is409($e->details['coin_id']).
Shop plugins — one invoice per order
use CryptoLike\Amount; use CryptoLike\ApiException; use CryptoLike\Client; $api = new Client([ 'key_id' => $settings['key_id'], 'secret' => $settings['secret'], 'base_url' => $settings['api_url'], 'integration' => ['platform' => 'woocommerce', 'platform_version' => WC_VERSION, 'plugin_version' => '1.0.0', 'store_url' => home_url()], ]); try { $invoice = $api->createInvoiceForOrder( (string) $order->get_id(), Amount::format($order->get_total(), wc_get_price_decimals()), // float/string total → "49.90", once $order->get_currency(), ['return_url' => $order->get_checkout_order_received_url(), 'description' => 'Order #' . $order->get_order_number()], ); // redirect the customer to $invoice['checkout_url'] } catch (ApiException $e) { if ($e->reason() === 'order_has_payment') { /* the order already received funds — never charge twice */ } if ($e->reason() === 'order_invoice_mismatch') { /* the total changed: cancel $e->details['invoice_id'] (if new) and retry */ } }
createInvoiceForOrder($orderId, $amount, $currency, $options = [], $metadata = []) sends
idempotency_by_order_id: true: a second call for the same order — double click, reload, retry —
returns the open invoice instead of creating another. The integration option goes into the
invoice metadata (never shown to the payer) and the User-Agent
(cryptolike-sdk-php/0.3.0 woocommerce/9.3.1 cryptolike-woocommerce/1.0.0), which is how the
platform counts payments per CMS. Amount::format($value, $decimals) converts the shop's total
exactly once (strings and ints exactly; a float rounded to the currency's decimals; more non-zero
decimals than allowed are refused); Amount::compare('10.50', $invoice['paid_amount']) compares
without floats. The CryptoLike plugins for WooCommerce, PrestaShop, OpenCart and Magento are built
on these two methods.
Errors
A non-2xx response throws ApiException ($status, $apiCode, getMessage(), $details,
field(), reason(), $retryAfter, $requestId); a transport failure that survived the retries throws
NetworkException. Codes: Authentication §5, API §5.
A payout with a key whose withdraw a developer requested and an owner/admin has not approved yet is
403 ApiException::WITHDRAW_PENDING_APPROVAL ($e->withdrawPendingApproval()): nothing was created,
not retried — approve the request in the cabinet (API keys); the key's other permissions work meanwhile.
Retries and idempotency
429 (after Retry-After, or backoff), 5xx except 501, and connection errors are retried up to
max_retries (default 3) with min(retry_max, retry_base · 2^attempt) full-jitter backoff. Every
POST carries an Idempotency-Key (yours, or a UUID generated once per call and reused on each
retry), so a retry never creates a second invoice, address or withdrawal. Each attempt is signed
with a fresh timestamp. Redirects are never followed. A 401 auth.replay — two identical requests
signed within the same second share a signature — waits for the next second and signs afresh.
Options
new Client([ 'key_id' => …, 'secret' => …, // default: CRYPTOLIKE_API_KEY_ID / CRYPTOLIKE_API_SECRET 'base_url' => …, // default: CRYPTOLIKE_API_URL or https://api.cryptolike.net 'transport' => new Psr18Transport($client, $requestFactory, $streamFactory), // default: CurlTransport, else StreamTransport 'timeout' => 30.0, 'max_retries' => 3, 'retry_base' => 0.5, 'retry_max' => 8.0, 'retry_after_max' => 30.0, 'api_version' => 'v1', // X-API-Version pin 'integration' => ['platform' => 'opencart', 'platform_version' => '4.0.2.3', 'plugin_version' => '1.0.0', 'store_url' => 'https://shop.example'], 'app_info' => 'my-shop/2.3', // appended to User-Agent: cryptolike-sdk-php/<version> … ]);
Transports: CurlTransport (default when ext-curl is loaded), StreamTransport (the fallback:
allow_url_fopen = On, ext-openssl for https; certificates verified, redirects not followed; a
clear NetworkException when neither is available), Psr18Transport (Guzzle, Symfony HttpClient —
needs psr/http-client + psr/http-factory).
Webhook verification
Webhook::verify(array $headers, string $rawBody, string $secret, ?int $tolerance = 300, ?int $now = null)
(the same as Client::verifyWebhook($headers, $rawBody, $secret, $tolerance); Webhook::verifyRequest($secret)
reads php://input and the headers of the current request) returns the decoded event (event_id, type, created_at, data) or throws
WebhookVerificationException with $reason (missing_header | invalid_timestamp |
timestamp_out_of_tolerance | invalid_signature | invalid_body). $rawBody must be the bytes
as received. Answer 401/400 on failure, 2xx on success, and deduplicate by event_id —
deliveries are at least once and may arrive out of order.