Source: https://cryptolike.net/integrations/sdk-php

# cryptolike/sdk (PHP)

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.

> **Pre-release:** Packagist has nothing under this name yet. Install from a checkout of the repository (`$CL` = path to the clone, see [SDK.md](../../docs/SDK.md#installing-from-the-repository-pre-release)); the command below is the one for after the public release.

```bash
composer config repositories.cryptolike path "$CL/sdk/php" && composer require cryptolike/sdk:@dev

# after the release:
composer require cryptolike/sdk
```

- Signed requests (`X-API-Key` / `X-Timestamp` / `X-Signature`, HMAC-SHA256 — [API-AUTH](../../docs/API-AUTH.md))
- `Idempotency-Key` generated for every `POST` (pass your own to make retries exact)
- Retries with exponential backoff and jitter on `429` (honours `Retry-After`), `5xx` and transport errors
- Every endpoint of the merchant API as a method; responses are arrays shaped as in [`docs/openapi.yaml`](../../docs/openapi.yaml); amounts are decimal strings, floats are refused
- `Webhook::verify()` / `Client::verifyWebhook()` — signature and timestamp check of webhook deliveries ([WEBHOOKS](../../docs/WEBHOOKS.md))
- 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:

```php
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_url` your server obtained — see [WIDGET §1](../../docs/WIDGET.md).

`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

```php
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']); }
```

```php
// 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/create-payment.php), [`examples/webhook.php`](examples/webhook.php).

## Methods

| Area | Methods |
|---|---|
| Invoices | `createInvoice($params, $idempotencyKey)`, `getInvoice`, `listInvoices($filters)`, `iterateInvoices($filters)`, `cancelInvoice` |
| 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`).

```php
$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:

```php
$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).

## Shop plugins — one invoice per order

```php
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.2.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: [API-AUTH §5](../../docs/API-AUTH.md), [API §5](../../docs/API.md).
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

```php
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.
