PHP SDK
cryptunnel/cryptunnel on Packagist wraps the merchant API for PHP 8.1+. No Composer dependencies - ext-curl and ext-json, which every PHP build ships - so it drops into WordPress, Laravel or a bare script alike.
composer require cryptunnel/cryptunnelEverything the package does is a plain HTTP call you can also make yourself - see the API reference. The package saves you the request building, the error mapping and the two parts people get wrong: webhook verification and polling.
Construction
use Cryptunnel\Cryptunnel;
$cryptunnel = new Cryptunnel('<merchant id>', '<api key>', sandbox: true);| Argument | Meaning |
|---|---|
$merchantId, $apiKey | From the merchant page in the cabinet. Sent as x-merchant-id and x-api-key on every request. |
sandbox | true marks every payment the client creates as a test payment and makes listCurrencies() return the testnet family. Same host, same key - see Sandbox and faucets. |
baseUrl | Defaults to https://api.cryptunnel.io. Point it at your own backend for local development. |
timeout | Seconds per request, 30 by default. |
app | Optional, e.g. 'my-shop/2.0' - appended to the User-Agent the client sends (cryptunnel-php/1.1.0 php/8.3.1 curl/8.7.1 (Linux x86_64)), so your own integration shows up in Cryptunnel's usage statistics next to the SDK version. |
Methods
Every method returns the decoded JSON of its endpoint as an array.
| Method | Endpoint | Returns |
|---|---|---|
createWidgetPayment($amount, $currency, $externalId, ?$successUrl, ?$failUrl, ?$callbackUrl, ?$metadata, ?$feePayer) | POST /v1/payments/widget | id, external_id, status, url (send the buyer here), created_at, is_test |
createH2hPayment($amount, $currency, $externalId, $targetCurrency, $autoTrace = false, ?$callbackUrl, ?$metadata, ?$feePayer) | POST /v1/payments/h2h | adds wallet_address, amount (crypto, decimal string), currency |
getPayment($paymentId) | GET /v1/payments/{id} | the payment - two shapes, see below |
listPayments($limit = 20, $offset = 0) | GET /v1/payments | items, total, limit, offset |
listCurrencies() | GET /v1/currencies | list of code, name, blockchain - exactly the values $targetCurrency accepts |
getMerchant() | GET /v1/merchants | id, name, balance, created_at - the call that proves the credentials work |
Webhook::verify($secret, $headers, $rawBody, $tolerance = 300) | none - local | true or false, never throws |
waitForPayment($paymentId, $timeout = 1800, $firstDelay = 5, $maxDelay = 30) | polls GET /v1/payments/{id} | the payment once it reaches a terminal status |
Optional arguments are named - createWidgetPayment(amount: 10, currency: 'USD', externalId: 'order-1', successUrl: '...') reads better than positional nulls.
createWidgetPayment and createH2hPayment are idempotent on $externalId: a retry after a timeout returns the payment you already created. Repeating an id with a different amount or currency throws ValidationException with apiCode PAYMENT_ALREADY_EXISTS.
getPayment returns two shapes and the status tells them apart: a created or expired payment has no amount, currency or wallet_address, because nobody has picked a currency for it yet. Read them with $payment['amount'] ?? null unless you know the status - see Payment statuses.
Errors
Every failure extends Cryptunnel\Exception\CryptunnelException; catch by class and read the raw API code from ->apiCode. getCode() holds the HTTP status.
| Exception | When |
|---|---|
AuthenticationException | 401 - wrong merchant id, wrong or rotated key, suspended merchant |
NotFoundException | 404 - PAYMENT_NOT_FOUND, CURRENCY_NOT_FOUND |
ValidationException | 400 - PAYMENT_ALREADY_EXISTS, WALLET_NOT_FOUND, VALIDATION_ERROR |
RateLimitException | 429 - ->retryAfter holds the seconds to wait, null if the API sent no header |
ApiException | 5xx and transport failures |
PaymentTimeoutException | waitForPayment gave up |
use Cryptunnel\Exception\RateLimitException;
use Cryptunnel\Exception\ValidationException;
try {
$cryptunnel->createH2hPayment(10, 'USD', 'order-3', 'DOGE');
} catch (ValidationException $error) {
echo $error->apiCode; // WALLET_NOT_FOUND
} catch (RateLimitException $error) {
echo $error->retryAfter;
}Verifying a webhook
Pass the raw body exactly as received - file_get_contents('php://input') - because parsing and re-serialising the JSON changes the signature. Webhook::verify reads the two headers from whatever you have: $_SERVER with its HTTP_ keys, getallheaders(), or a framework header bag.
use Cryptunnel\Webhook;
$rawBody = file_get_contents('php://input');
if (!Webhook::verify(WEBHOOK_SECRET, $_SERVER, $rawBody)) {
http_response_code(401);
exit;
}
$payment = json_decode($rawBody, true);
...
http_response_code(200);use Cryptunnel\Webhook;
use Illuminate\Http\Request;
Route::post('/cryptunnel', function (Request $request) {
if (!Webhook::verify(config('services.cryptunnel.secret'), $request->headers->all(), $request->getContent())) {
abort(401);
}
$payment = $request->json()->all();
...
return response('', 200);
});The full contract - payload fields, retries, deduplication - is on the webhooks page.
Waiting for a payment
waitForPayment polls every 5 seconds, backing off to 30, honours Retry-After on a 429, and throws PaymentTimeoutException after 30 minutes. It exists for scripts and development. In production the webhook is the guarantee: a buyer who closes the page still produces a callback, a polling process that dies does not.
Links
- Package: packagist.org/packages/cryptunnel/cryptunnel
- Source and issues: github.com/cryptunnel/cryptunnel-php
Updated 9 days ago
