# How to accept crypto payments in PHP

> Accept Bitcoin and USDT on a PHP site: sign an API request with hash_hmac, create a payment, send the customer to the payment page and verify the webhook.

Source: https://cryptolike.net/guides/accept-crypto-payments-php
Published: 2026-09-24 · Updated: 2026-10-04 · Author: CryptoLike team

A PHP site accepts crypto with CryptoLike in three pieces of code: a function that signs API requests with `hash_hmac`, a call that creates the payment for an order, and a webhook handler that marks the order paid. No extension beyond cURL is needed, and the same code runs on PHP 7.4 and 8.x, in Laravel, Symfony or plain PHP.

This guide is for a custom site or application. A shop on WooCommerce, OpenCart, PrestaShop or Magento 2 needs no code: install the [WooCommerce plugin](https://cryptolike.net/integrations/woocommerce) or the module for your platform instead.

## What you need

A CryptoLike account, a PHP server that can make outgoing HTTPS requests, and a public `https://` URL for the webhook. Keep the API secret and the webhook secret in environment variables — the examples read `CRYPTOLIKE_API_KEY_ID`, `CRYPTOLIKE_API_SECRET` and `CRYPTOLIKE_WEBHOOK_SECRET`, plus `CRYPTOLIKE_API_URL` for the test API URL (unset means production). Test everything first with test keys and test networks, so no real money moves.

## 1. Create a test API key

In the cabinet open **Developers → API keys → Create key** and give the key `create_invoice` and `read`. Keys that start with `ck_test_` work with test networks. Test keys come from the test environment: CryptoLike support gives you access to its cabinet, where you create the same key, and its API URL (`CRYPTOLIKE_API_URL` in the examples). The production cabinet issues live keys only, and on mainnet a test key is refused with `401 auth.test_key`; switch to a live key only when the flow works end to end. The secret is shown once: put it straight into your server's environment, never into a repository or a page.

## 2. Sign API requests with hash_hmac

Every request carries three headers: the key ID, the Unix time in seconds and an HMAC-SHA256 signature of four lines — the timestamp, the method, the path with the query, and the SHA-256 of the body. This function does it with cURL:

```php
function cryptolike(string $method, string $path, ?array $payload = null, ?string $idempotencyKey = null): array
{
    $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES);
    $ts = (string) time();
    $canonical = implode("\n", [$ts, strtoupper($method), $path, hash('sha256', $body)]);
    $headers = [
        'X-API-Key: ' . getenv('CRYPTOLIKE_API_KEY_ID'),
        'X-Timestamp: ' . $ts,
        'X-Signature: ' . hash_hmac('sha256', $canonical, getenv('CRYPTOLIKE_API_SECRET')),
        'Content-Type: application/json',
    ];
    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }
    $ch = curl_init((getenv('CRYPTOLIKE_API_URL') ?: 'https://api.cryptolike.net') . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => $body === '' ? null : $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $data = json_decode((string) $response, true) ?? [];
    if ($status >= 400) {
        throw new RuntimeException(($data['error']['code'] ?? 'http_' . $status) . ': ' . ($data['error']['message'] ?? ''));
    }
    return $data;
}
```

The body you hash must be the exact string you send, which is why the function encodes it once. Check your signing against the reference vector in [API authentication](https://cryptolike.net/docs/auth) before the first real call: a `401 auth.invalid_signature` almost always means a different body string or the time in milliseconds.

## 3. Create the payment for an order

Create one payment per order from the server. Price it in your currency and let the customer choose the coin, or pass a `coin_id` from `GET /v1/currencies` to fix it. Without `coin_id` the payment page lists the coins you offer for invoices, each with an approximate amount marked ≈; to offer only some of them for this order, add `allowed_coin_ids` with ids that have `invoice_enabled: true` in the signed `GET /v1/currencies`. The order ID as the idempotency key makes a double click or a retry return the same payment:

```php
$invoice = cryptolike('POST', '/v1/invoices', [
    'amount' => '49.90',
    'currency' => 'USD',
    'order_id' => $order->id,
    'idempotency_by_order_id' => true,
    'return_url' => 'https://example.com/orders/' . $order->id,
], 'order-' . $order->id);

$order->saveInvoiceId($invoice['id']);
```

Amounts are decimal strings, never floats: `'49.90'`, not `49.9`. The response carries `id`, `status` (`new`) and `checkout_url`.

## 4. Send the customer to the payment page

Redirect to `checkout_url`: the page shows the amount, the network, the address with a QR code, the time left and the live status, and returns the customer to `return_url` after the payment. To keep the customer on your site, embed the same page with the [checkout widget](https://cryptolike.net/docs/widget). Your page only ever receives the invoice ID; the API secret stays on the server.

```php
header('Location: ' . $invoice['checkout_url'], true, 303);
exit;
```

## 5. Verify the webhook and mark the order paid

Add an endpoint in the cabinet under **Developers → Webhooks** with the `invoice.*` events and store its secret. The handler verifies the signature over the raw body, rejects old timestamps, skips events it has already processed and answers `200` quickly:

```php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret = (string) getenv('CRYPTOLIKE_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);

if ($secret === '' || !ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
if (!alreadyProcessed($event['event_id'])) {
    if ($event['type'] === 'invoice.paid') {
        markOrderPaid($event['data']['invoice']['order_id']);
    }
    rememberEvent($event['event_id']);
}
http_response_code(200);
```

Read the body with `php://input`, not `$_POST`, and do not decode and re-encode it before the check. Deliveries are at least once and can arrive out of order, so trust the status inside `data`, not the arrival order. More detail and the test vector: [verify crypto payment webhooks](https://cryptolike.net/guides/verify-crypto-payment-webhooks).

## 6. Test on a test network, then go live

Create a payment with the test key, pay it with test coins on the network shown on the page, and watch `invoice.pending` and then `invoice.paid` arrive. Then handle the other outcomes: `invoice.underpaid` (the customer can top up to the same address until the deadline), `invoice.expired` (offer a new payment) and `invoice.reverted` (stop fulfilment). Replace the test key with a live one and run the go-live checklist in the [developer guide](https://cryptolike.net/docs).

## Common errors and fixes

| Error | What happened | What to do |
|---|---|---|
| `401 auth.invalid_signature` | The signed string differs from the request | Hash the exact body you send; sign the path with its query; use seconds, not milliseconds |
| `401 auth.expired_timestamp` | The server clock is more than 5 minutes off | Sync the clock with NTP |
| `403 auth.permission_denied` | The key lacks a permission | Add `create_invoice` or `read` to the key in the cabinet |
| `409 conflict` | The order already has a payment for another amount, or has received money | Read `details.invoice_id`; do not create a second payment |
| `422 idempotency.mismatch` | The same `Idempotency-Key` came with a different body | Use one key per order and per request |
| `422 no_allowed_coins` | None of `allowed_coin_ids` is offered for invoices in this currency | Send other ids, or leave the field out to offer every coin |

## Prefer an SDK?

The official [PHP SDK](https://cryptolike.net/integrations/sdk-php) wraps the same calls for PHP 7.4 – 8.x with no runtime dependencies: signed requests, retries on `429` and `5xx`, one open invoice per order and `Webhook::verify()`. The code in this guide is what it does under the hood, so you can start with either.

## FAQ

### Do I need Composer or a library to accept crypto in PHP?

No. Signing a CryptoLike request takes `hash()` and `hash_hmac()` from PHP's standard library, and the requests go through cURL. The function in step 2 is enough for a whole integration. The official PHP SDK adds retries, pagination and helpers for shops, and it also works without Composer by copying its files into your project.

### Can I show the payment page on my own site?

Yes. Create the payment on your server as in step 3, then embed the same payment page with the checkout widget instead of redirecting. Add your site's address to the allowed origins first: the **Allowed origins** card on the cabinet's Settings page. The widget reads only public payment data, so the API secret never reaches the browser.

### How do I know which order a webhook belongs to?

The `invoice` object in the webhook carries the `order_id` you sent when you created the payment, and `metadata` if you sent it. Look the order up by that value, check the status inside `data`, and store the `event_id` so a repeated delivery of the same event is acknowledged without changing the order twice.
