Search by

qvard / qvard-php

qvard

There is no license information available for the latest version (v0.1.0) of this package.

Qvard payments API client for PHP: payments, payouts, rates and webhook signature checks

v0.1.0 2026-11-13 20:32 UTC

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 a qvard_sk_... key.
  • The SDK sends Authorization: Bearer <key>. It never sends x-api-key.

Methods

CallRequestReturns
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/payouttrue
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.

  • id in payments->create is 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 answers 409 once money is on the way.
  • payments->get answers error.notfound for a payment of another shop, the same as for an unknown uuid.
  • price is sent as you give it. The server converts it to USD at the current rate and stores that rate with the payment. Keys with a null value 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 \InvalidArgumentException for 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 5xx answer (including 502, 503 and 504 from a gateway), or a 2xx answer whose body is not a JSON object (an array, a string, null or an empty body). The error's friendlyMessage says: 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's status is the HTTP status and message is the server's key, or HTTP <status> when there is none.
  • A 4xx answer is a refusal: the payout was not created, and message is the reason (for example Insufficient balance).
  • To retry safely, send the same id and 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 is 400.
  • The server stores id on 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 id cannot both create a payout.
  • Scope: the key is matched within your shop and the methodId. The same id on a different method creates a second payout. The same id in a different shop is independent.
  • A repeated request with the same id returns the original payout, and the API answers true exactly as it does for a new payout. The response does not say that it was a replay.
  • A repeated request with the same id but a different price, address or comment is 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 example Insufficient balance) created nothing, so sending it again with the same id is 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) returns true or false. It throws \InvalidArgumentException only when no secret is given.
  • $qvard->webhooks->constructEvent($rawBody, $header, $secret) returns the parsed event as an array, or throws \RuntimeException when the signature fails.
  • The check accepts a timestamp within $toleranceSec (300 seconds) of now, in either direction, and accepts any v1 that matches. The comparison is constant-time.
  • The event body is {id, status, paymentLink?, customerEmail?, amount, currency, amountUsd, rate}. There is no event type field.
  • 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 example error.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's traceId. 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()StatusMeaning
error.wrong.api.key401Missing or unknown key
error.notfound404Not in your shop
error.shop.not.active403Shop is not active
error.origin.not_allowed403Origin not in the allowed list (publishable-key flow only)
error.origin.not_configured403No allowed origins configured (publishable-key flow only)
error.payment.duplicate_id409Same id, but a different amount, or the payment is already paid
Error validation400Body rejected; see errors
unsupported_currency422Currency not supported
invalid_amount_precision422Too many decimal places for the currency
rate_unavailable422No rate for the currency right now
ThrottlerException: Too Many Requests429Rate limit
error.internal500Failure 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 3xx answer is thrown as a QvardError, so the secret key is never sent to another host. (@qvard-sdk/node does 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.