qvard / qvard-php
Qvard payments API client for PHP: payments, payouts, rates and webhook signature checks
Requires
- php: >=8.1
- ext-curl: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-13 23:49:36 UTC
README
PHP client for the Qvard payments API. No dependencies beyond the curl extension, PHP 8.1 or newer.
Install
composer require qvard/qvard-php
Quick start
<?php
use Qvard\Qvard;
$qvard = new Qvard((string) getenv('QVARD_SECRET_KEY')); // qvard_sk_...
$payment = $qvard->payments->create(['id' => 'order-1001', 'price' => 10, 'currency' => 'EUR']);
// send the payer to $payment['redirectUrl']
$info = $qvard->payments->get($payment['id']);
echo $info['status']; // wait | partial | completed | rejected
Options: new Qvard($secretKey, ['baseUrl' => ..., 'timeoutMs' => ...]). baseUrl defaults to https://api.qvard.com, timeoutMs to 30000 (milliseconds).
Keys
- Use the secret key
qvard_sk_...on your server only. Never put it in browser or mobile code. A publishable key (qvard_pk_...) is rejected by the constructor because it is for the browser only. - Legacy keys without the
qvard_sk_prefix are rejected by the constructor. Rotate the key in the cabinet (Shop settings → API keys) to get aqvard_sk_...key. - The SDK sends
Authorization: Bearer <key>. It never sendsx-api-key.
Methods
| Call | Request | Returns |
|---|---|---|
payments->create(['id', 'price', 'currency'?, 'paymentSystemId'?]) | POST /api/v1/payment | ['id', 'redirectUrl', 'telegramUrl'?] |
payments->get($id) | GET /api/v1/payment/:id | ['id', 'status', 'createdAt', 'paidAt', 'price', 'currency', 'paidAmount', 'token', 'expiresAt'] |
payouts->create(['id', 'price', 'methodId', 'address', 'comment'?]) | POST /api/v1/payout | true |
methods->info($id) | GET /api/v1/method/:id/info | ['server', 'isReady', 'isActive'] |
rates->list() | GET /api/v1/rates | ['base', 'updatedAt', 'rates'] |
The results are the server's data as associative arrays, with the server's field names.
idinpayments->createis your own order id. The server requires it. The same id with the same amount and currency returns the existing payment. A different amount replaces an unpaid one, and the server answers409once money is on the way.payments->getanswerserror.notfoundfor a payment of another shop, the same as for an unknown uuid.priceis sent as you give it. The server converts it to USD at the current rate and stores that rate with the payment. Keys with anullvalue are not sent.Qvard::convertForDisplay($amount, $from, $to, $rates)is for showing prices only. Pass$qvard->rates->list()['rates']. The total a payer pays is computed by the server; do not charge the value this method returns. It throws\InvalidArgumentExceptionfor a currency without a rate.
Payouts
payouts->create sends money out of your balance. Read this before you call it.
- The SDK never retries a payout.
- The result is unknown on a timeout, a network error, a
5xxanswer (including502,503and504from a gateway), or a2xxanswer whose body is not a JSON object (an array, a string,nullor an empty body). The error'sfriendlyMessagesays:Result unknown: check whether the payout went through before retrying (reuse the same id).. A payout may have been created. In that case the error'sstatusis the HTTP status andmessageis the server's key, orHTTP <status>when there is none. - A
4xxanswer is a refusal: the payout was not created, andmessageis the reason (for exampleInsufficient balance). - To retry safely, send the same
idand the same body as the first attempt. See the next section.
Payout idempotency
What protects a payout from being paid twice is the id field of the request body, which is your payout id.
- The server requires
id: without it the answer is400. - The server stores
idon the withdrawal as its idempotency key. Before it creates a withdrawal it looks for an existing withdrawal of your shop with the same method and the same key. If it finds one, it returns that one and creates nothing. - The lookup runs under a database lock for your shop and method, so two simultaneous requests with the same
idcannot both create a payout. - Scope: the key is matched within your shop and the
methodId. The sameidon a different method creates a second payout. The sameidin a different shop is independent. - A repeated request with the same
idreturns the original payout, and the API answerstrueexactly as it does for a new payout. The response does not say that it was a replay. - A repeated request with the same
idbut a differentprice,addressorcommentis not compared. The original payout stands and the new values are ignored. - The lookup finds only payouts that were created. A request that was rejected with
400(for exampleInsufficient balance) created nothing, so sending it again with the sameidis a new attempt. - After a key rotation the previous key is not accepted on
POST /api/v1/payout, even during the 24-hour grace period. - v1 has no endpoint to look up a payout by your id. To check whether a payout from a timed-out call exists, look in the cabinet.
Webhooks
Qvard calls your callback URL with a JSON body and the header X-Qvard-Signature: t=<unix>,v1=<hex>[,v1=<hex>]. The signature is HMAC-SHA256, keyed with your secret key, over t . "." . body. Two v1 values are sent for 24 hours after a key rotation, and either one is valid.
Verify the signature over the raw body, before any JSON parsing. Read it with file_get_contents('php://input'), not from $_POST:
<?php
use Qvard\Qvard;
$secret = (string) getenv('QVARD_SECRET_KEY');
$qvard = new Qvard($secret);
$rawBody = file_get_contents('php://input');
try {
$event = $qvard->webhooks->constructEvent($rawBody, $_SERVER['HTTP_X_QVARD_SIGNATURE'] ?? null, $secret);
} catch (\Exception $e) {
http_response_code(400);
exit;
}
// $event['id'] is YOUR order id (the one you passed to payments->create), not the payment uuid.
// Check $event['amount'] and $event['currency'] against your order before you fulfil it.
http_response_code(200);
$qvard->webhooks->verify($rawBody, $header, $secret, $toleranceSec = 300)returnstrueorfalse. It throws\InvalidArgumentExceptiononly when no secret is given.$qvard->webhooks->constructEvent($rawBody, $header, $secret)returns the parsed event as an array, or throws\RuntimeExceptionwhen the signature fails.- The check accepts a timestamp within
$toleranceSec(300 seconds) of now, in either direction, and accepts anyv1that matches. The comparison is constant-time. - The event body is
{id, status, paymentLink?, customerEmail?, amount, currency, amountUsd, rate}. There is no eventtypefield. - Answer
200. Qvard treats any other status as a failed delivery and retries it.
Errors
Every failed call throws Qvard\QvardError:
$e->status: the HTTP status.$e->getMessage(): the server's key, for exampleerror.wrong.api.key, or the text it sent.$e->friendlyMessage: an English explanation from the shared dictionary (sdk/conformance/errors.json, copied into the package). An unknown key is returned as is.$e->errors: for a rejected request body, a map from each field to its first message.$e->requestId: the server'straceId. Quote it to Qvard support.
A request that got no answer (connection error, DNS, timeout) throws Qvard\QvardNetworkError, a subclass of QvardError with status 0.
use Qvard\QvardError;
use Qvard\QvardNetworkError;
try {
$qvard->payments->get($uuid);
} catch (QvardNetworkError $e) {
echo 'no answer: ' . $e->friendlyMessage;
} catch (QvardError $e) {
echo $e->status . ' ' . $e->getMessage() . ' ' . $e->friendlyMessage . ' ' . $e->requestId;
}
getMessage() | Status | Meaning |
|---|---|---|
error.wrong.api.key | 401 | Missing or unknown key |
error.notfound | 404 | Not in your shop |
error.shop.not.active | 403 | Shop is not active |
error.origin.not_allowed | 403 | Origin not in the allowed list (publishable-key flow only) |
error.origin.not_configured | 403 | No allowed origins configured (publishable-key flow only) |
error.payment.duplicate_id | 409 | Same id, but a different amount, or the payment is already paid |
Error validation | 400 | Body rejected; see errors |
unsupported_currency | 422 | Currency not supported |
invalid_amount_precision | 422 | Too many decimal places for the currency |
rate_unavailable | 422 | No rate for the currency right now |
ThrottlerException: Too Many Requests | 429 | Rate limit |
error.internal | 500 | Failure inside Qvard; quote requestId |
Payout rejections come back as plain English text, for example Insufficient balance, Method not found or Invalid TRON withdrawal address, with status 400. The dictionary does not list them; friendlyMessage equals the message for them.
Differences from the Node.js package
- Redirects are not followed. A
3xxanswer is thrown as aQvardError, so the secret key is never sent to another host. (@qvard-sdk/nodedoes the same.)
Tests
No test dependencies (PHPUnit is not used). From sdk/php:
php tests/run.php
The tests read the shared fixtures from ../conformance, so they run inside the sdk/ folder. One test starts php -S on 127.0.0.1 to check the real curl transport.