velirapay / velirapay-php
The official PHP library for VeliraPay crypto payments: charges, payment links, invoices, events and webhook verification.
Requires
- php: ^8.2
- ext-ctype: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-client-implementation: ^1.0
- psr/http-factory: ^1.0
- psr/http-factory-implementation: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8 || ^8.0
- laravel/pint: ^1.13
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.3 || ^13.0
Suggests
- guzzlehttp/guzzle: The HTTP client this library creates when none is given, with the timeout option applied (^7.8).
Provides
None
Conflicts
None
Replaces
None
README
The official PHP library for VeliraPay, the crypto payment processor. It covers the whole v1 API (charges, payment links, invoices, coupons, events and your account) and verifies the webhooks VeliraPay sends you.
- Requirements
- Installation
- Getting started
- Charges
- Payment links
- Invoices
- Coupons
- Events
- Your account
- Lists and pagination
- Webhooks
- Errors
- Retries and idempotency
- Test mode
- Configuration
- Testing your integration
- Laravel
Requirements
- PHP 8.2 or later, with the
ctypeextension (enabled by default) - A PSR-18 HTTP client. Laravel, Symfony and most frameworks already have one, and Guzzle is used when it is installed.
Installation
composer require velirapay/velirapay-php
When Composer asks whether to trust php-http/discovery, answer yes: it installs an HTTP client if your project has none. You can also install Guzzle yourself:
composer require guzzlehttp/guzzle
Getting started
Create an API key in the dashboard under Developers → API keys. Keep it on your server: anyone holding it can create charges and read your payments.
use VeliraPay\VeliraPayClient; $velirapay = new VeliraPayClient(getenv('VELIRAPAY_API_KEY')); $charge = $velirapay->charges->create([ 'amount' => '49.90', 'currency' => 'EUR', 'asset' => 'BTC', 'customer' => ['email' => 'ada@example.com'], 'metadata' => ['order_id' => '1042'], ]); header('Location: '.$charge->checkoutUrl);
The customer pays on the hosted checkout page. VeliraPay then tells your server about it with a webhook.
Amounts are always strings, such as '49.90', never floats, so no precision is lost. Use bcmath or a money library to do arithmetic on them.
Each group of endpoints is a property, such as $velirapay->charges, and also a method, $velirapay->charges(), which facades need.
Charges
A charge is one payment in one coin. The exchange rate is locked when it is created, and the customer has 30 minutes to pay.
use VeliraPay\Enums\Asset; use VeliraPay\Enums\ChargeStatus; // For an amount you set. $charge = $velirapay->charges->create([ 'amount' => '150.00', 'currency' => 'USD', 'asset' => Asset::USDC, // USD Coin on Ethereum 'description' => 'Pro plan, 1 year', 'metadata' => ['order_id' => '1042'], ]); // For one of your payment links: the currency comes from the link, and so does the price when it is fixed. $charge = $velirapay->charges->create(['payment_link' => 'p4n8r2t6y1ua', 'asset' => 'ETH']); // With one of your coupons: fiatAmount is the lower price, and discountAmount what the coupon took off. $charge = $velirapay->charges->create(['payment_link' => 'p4n8r2t6y1ua', 'asset' => 'ETH', 'coupon' => 'SPRING10']); $charge->coupon; // "SPRING10" $charge->discountAmount; // "15.00" $charge = $velirapay->charges->retrieve('k3v9x2m7q8wz'); $charge->status; // "pending", "underpaid", "paid", "expired" or "canceled" $charge->isPaid(); $charge->assetAmount; // "0.0025", what the customer has to send $charge->receivedAmount; // what arrived so far $charge->depositAddress; $charge->transactions; // list of VeliraPay\Resources\Transaction $charge->metadata; // ['order_id' => '1042'] $paid = $velirapay->charges->list(['status' => ChargeStatus::Paid]); // Only while the charge is pending and no transfer to it has been seen. $velirapay->charges->cancel('k3v9x2m7q8wz'); // Record a refund you sent from your own wallet. $velirapay->charges->recordRefund('k3v9x2m7q8wz', [ 'amount' => '0.0005', 'txid' => '7d1c5e9a…', 'reason' => 'Returned item', ]);
asset takes a VeliraPay\Enums\Asset case or its value, such as 'BTC'. The enum lists every coin the API knows, but not all of them can be paid in at any given time: a coin is only offered while VeliraPay can also pay it out, and a network can be switched off. $account->acceptedAssets lists the coins your account takes right now (Your account); a charge in any other coin throws a ValidationException.
metadata holds up to 20 keys of at most 40 characters each. Values are strings, numbers, booleans or null, and a string is at most 500 characters.
An account can create 300 charges in each mode within 30 minutes, through the API, the MCP server and the dashboard together; canceled charges count, paid live ones do not, and payments customers start on the hosted checkout are not counted. Past that, create() throws a RateLimitException whose retryAfter() says how many seconds to wait.
Customer details
$charge->customer holds what is known about who pays, to help you spot fraud before you ship anything. When the customer starts the payment on the hosted checkout, VeliraPay records the IP address and browser they did it from. A charge you create from your server comes from your server rather than the customer, so pass what you know:
$charge = $velirapay->charges->create([ 'amount' => '150.00', 'currency' => 'USD', 'asset' => 'BTC', 'customer' => [ 'email' => 'ada@example.com', 'name' => 'Ada Lovelace', 'ip_address' => $_SERVER['REMOTE_ADDR'], 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, 'reference' => 'cus_1042', // your own id for the customer 'phone' => '+44 20 7946 0958', 'country' => 'GB', // ISO 3166 two-letter code 'metadata' => ['orders' => 7], // the same limits as the charge's metadata ], ]); $charge->customer->ipAddress; // "203.0.113.7" $charge->customer->userAgent; $charge->customer->country; // "GB" $charge->customer->metadata; // ['orders' => 7]
Every field is optional, and any other key is refused with a ValidationException. The older customer_email parameter still works.
Payment links
A payment link is a reusable checkout page, for a fixed price or one the customer chooses.
use VeliraPay\Enums\Asset; use VeliraPay\Enums\PricingType; $link = $velirapay->paymentLinks->create([ 'title' => 'Pro plan', 'pricing_type' => PricingType::Fixed, 'amount' => '150.00', 'currency' => 'EUR', 'accepted_assets' => [Asset::BTC, Asset::ETH, Asset::USDC], // null accepts every coin the account takes 'success_url' => 'https://example.com/thanks', 'custom_fields' => [['label' => 'Company', 'required' => false]], ]); echo $link->checkoutUrl; // https://velirapay.com/pay/… $tipJar = $velirapay->paymentLinks->create([ 'title' => 'Tip jar', 'pricing_type' => PricingType::Open, 'currency' => 'USD', 'suggested_amounts' => ['5.00', '10.00', '25.00'], 'min_amount' => '1.00', ]); $velirapay->paymentLinks->update($link->id, ['title' => 'Pro plan (yearly)']); $velirapay->paymentLinks->archive($link->id); // stops taking payments $velirapay->paymentLinks->restore($link->id);
Invoices
An invoice is a bill with a hosted page, where the customer pays in the coin of their choice.
$invoice = $velirapay->invoices->create([ 'customer_name' => 'Grace Hopper', 'customer_email' => 'grace@example.com', 'currency' => 'USD', 'items' => [ ['description' => 'Consulting', 'quantity' => '10', 'unit_amount' => '100.00'], ['description' => 'Hosting', 'quantity' => '1', 'unit_amount' => '250.00'], ], 'due_at' => new DateTimeImmutable('+14 days'), 'send_email' => true, ]); echo $invoice->number; // INV-0042 echo $invoice->hostedUrl; $velirapay->invoices->send($invoice->id); // email it, or send a reminder $velirapay->invoices->void($invoice->id);
Pass amount instead of items to bill a single total.
An invoice can be emailed once every 10 minutes, and an account can send 100 emails to customers an hour: invoices, payment requests and refund notices together, shared with the other accounts its owner owns. Past either limit, send() throws a RateLimitException whose retryAfter() says how many seconds to wait, and create() with send_email throws it and creates nothing. In test mode, invoices are only emailed to members of your account: anyone else is refused with a ValidationException on customer_email, by send() as by create() with send_email.
Coupons
A coupon takes a percentage or a fixed amount off a payment. Customers type its code on the hosted checkout of a payment link, you add it to the link's URL so it is already applied (https://velirapay.com/pay/p4n8r2t6y1ua?coupon=SPRING10), or you pass it as coupon when you create a charge.
$coupon = $velirapay->coupons->create([ 'code' => 'SPRING10', // 3 to 32 letters, digits, dashes or underscores, stored in capitals 'percent_off' => '10', // or 'amount_off' => '5.00' with a 'currency' 'max_redemptions' => 100, // an open charge holds a use until it is paid, expires or is canceled 'once_per_customer' => true, // the customer's email is then required to use it 'payment_links' => ['p4n8r2t6y1ua'], // empty for every link, and charges made without one 'expires_at' => '2026-12-31', // a bare date ends at 23:59:59 UTC ]); $coupon->id; // "SPRING10": the code is the id, and is matched in any case $coupon->timesRedeemed; // how many paid charges used it $velirapay->coupons->update('SPRING10', ['active' => false]); // pause it $velirapay->coupons->delete('SPRING10'); // charges that used it keep its code and their discount
currency is required with amount_off or min_amount, and the coupon then only works on payments in that currency. Once a coupon exists, its code, discount, currency and min_amount cannot change: update() takes max_redemptions, once_per_customer, payment_links (which replaces the whole list), active and expires_at.
A coupon that cannot be used throws a ValidationException on coupon from charges->create(), saying why: it does not exist in this mode, is paused, has expired, is limited to other links, is for another currency, needs a higher price, would leave nothing to pay or is used up, or it is once per customer and the charge has no customer email or that customer already used it.
Events
Every change to a charge or an invoice is recorded as an event, such as charge.paid or invoice.viewed. Webhooks carry the same events.
use VeliraPay\Enums\EventType; $events = $velirapay->events->list(['charge' => 'k3v9x2m7q8wz']); $payments = $velirapay->events->list(['type' => EventType::ChargePaymentDetected]); $event = $velirapay->events->retrieve('9b1f7a3e-2c4d-4e8f-a6b0-1d2c3e4f5a6b'); $event->is(EventType::ChargePaid); $event->details; // what was recorded, such as the amount and txid of a payment or a refund $event->charge; // the charge as it is now, without its timeline
Your account
$account = $velirapay->account->retrieve(); $account->displayName; $account->supportEmail; $account->supportPhone; // "+442071234567", shown to customers next to the support email $account->acceptedAssets; // ['BTC', 'ETH', …], the coins it takes right now, in the key's mode $account->mode; // "live" or "test" $business = $account->business; // as entered under Business details in the dashboard's settings $business->type; // a VeliraPay\Enums\BusinessType, such as BusinessType::Company $business->legalName; $business->registrationNumber; $business->taxId; $business->address->line1; // also line2, city, postalCode, state and country (ISO 3166 two-letter code) $business->industry; // a VeliraPay\Enums\Industry, such as Industry::Software $business->productDescription; $business->monthlyVolume; // a VeliraPay\Enums\MonthlyVolume, such as MonthlyVolume::Under50k ("10k_50k") $business->phone; // in international format
Every business field is null until it is filled in. type, industry and monthlyVolume are also null for a value the API adds after this version of the library; $business->get('industry') still returns it as sent.
Lists and pagination
list() returns one page, newest first. Pass page and per_page (up to 100, 25 by default) to move through it:
$page = $velirapay->charges->list(['per_page' => 100]); foreach ($page as $charge) { // … } $page->total; $page->hasMore(); $next = $page->nextPage(); // null on the last page
iterator() walks every page for you, fetching each one as it is reached:
foreach ($velirapay->charges->iterator(['status' => 'paid']) as $charge) { // … }
Webhooks
Add an endpoint in the dashboard under Developers → Webhooks and copy its signing secret (whsec_…). Each delivery is a POST with a JSON body and these headers:
| Header | |
|---|---|
X-VeliraPay-Signature |
t={timestamp},v1={signature} |
X-VeliraPay-Event |
The event type, such as charge.paid |
X-VeliraPay-Delivery |
The delivery's id |
Webhook::constructEvent() checks the signature and parses the body. Pass it the raw body: a body decoded and encoded again no longer matches its signature.
use VeliraPay\Exceptions\SignatureVerificationException; use VeliraPay\Webhooks\Webhook; $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_VELIRAPAY_SIGNATURE'] ?? null; try { $event = Webhook::constructEvent($payload, $signature, getenv('VELIRAPAY_WEBHOOK_SECRET')); } catch (SignatureVerificationException $exception) { http_response_code(400); exit; } // The same event can arrive more than once: remember this key, and skip the events you have handled. $key = $event->eventId ?? $event->id; switch ($event->type) { case 'charge.payment_detected': // Seen on-chain, not confirmed yet: tell the customer, but do not fulfil. $txid = $event->transaction->txid; break; case 'charge.paid': $orderId = $event->charge->metadata['order_id']; // Fulfil the order. break; case 'charge.expired': // Release the stock. break; } http_response_code(200);
In Laravel, the route has to be left out of CSRF protection. The simplest way is to register it in routes/api.php, which has none (php artisan install:api creates the file), and read the body with $request->getContent():
// routes/api.php, so the endpoint is https://your-app.example/api/webhooks/velirapay use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use VeliraPay\Exceptions\SignatureVerificationException; use VeliraPay\Webhooks\Webhook; Route::post('/webhooks/velirapay', function (Request $request) { try { $event = Webhook::constructEvent( $request->getContent(), $request->header(Webhook::SIGNATURE_HEADER), config('services.velirapay.webhook_secret'), ); } catch (SignatureVerificationException) { abort(400); } if ($event->is('charge.paid')) { ProcessPaidCharge::dispatch($event->charge->id); } return response()->noContent(); });
To keep the route in routes/web.php, leave its path out of CSRF protection in bootstrap/app.php instead:
->withMiddleware(function (Middleware $middleware): void { $middleware->validateCsrfTokens(except: ['webhooks/velirapay']); })
Calling ->withoutMiddleware(ValidateCsrfToken::class) on the route, as earlier versions of this README suggested, no longer works on Laravel 13: CSRF protection is now the PreventRequestForgery middleware, and ValidateCsrfToken is only a deprecated subclass of it, so leaving it out leaves nothing out. Laravel 13 also renames validateCsrfTokens() to preventRequestForgery(), though the old name still works.
With a PSR-7 request, use Webhook::constructEventFromRequest($request, $secret).
Some things to know:
- Answer quickly. A delivery counts as delivered when your endpoint answers with a 2xx status within 15 seconds. Queue slow work instead of doing it before answering. Redirects are not followed, so a 3xx counts as a failure: give the endpoint's final URL.
- Failed deliveries are retried. A delivery is tried 14 times over about 3 days: after 1 minute, 5 minutes, 30 minutes, 1 hour, 2 hours, 4 hours and 8 hours, then every 10 hours. When one runs out of attempts, the account's owners and admins are emailed (at most once a day per endpoint), and an endpoint that fails 15 deliveries in a row is switched off until you switch it back on in the dashboard. While it is off, no new events are queued for it and the deliveries still waiting are marked failed; switching it back on does not send what it missed. The endpoint's delivery log keeps every attempt for 30 days, and can send a delivery again.
- Skip duplicates by event. A retried delivery keeps its id (
$event->id, also theX-VeliraPay-Deliveryheader), but that id belongs to one endpoint: each endpoint gets its own copy of an event, under its own id.$event->eventIdis the same on every copy and is the event's id inGET /v1/events, so$event->eventId ?? $event->idis the key to remember. - Fulfil once. A paid invoice sends both
charge.paid, for the charge that paid it, andinvoice.paid. Fulfil on one of them, not both. - Deliveries can arrive out of order. When the order matters, fetch the current state with
$velirapay->charges->retrieve($event->charge->id)rather than trusting the payload. - Test deliveries. The dashboard's "Send test event" button sends a sample
charge.paidwith$event->testset totrueand noeventId. Its charge is a made-up sample without many of a real one's fields, such astransactionsandcustomer. - Signatures expire. A signature older than 5 minutes is rejected, which stops an intercepted delivery from being replayed. Change the limit with the
toleranceargument.
charge.payment_detected arrives as soon as a transfer to a charge is seen on-chain, before it is confirmed; charge.late_payment when one reaches a charge that already expired, was canceled or was paid. Both carry that transfer as $event->transaction (txid, amount, confirmations, requiredConfirmations, credited, explorerUrl, seenAt); for every other event it is null.
The charge and invoice in a delivery are not quite the ones the API returns:
- A charge's coin amounts and exchange rate carry all 18 decimal places (
"0.002500000000000000"rather than"0.0025"). Compare amounts as numbers, not as strings. - A charge has no
timeline,refunds,checkoutUrlorreceiptUrl. Itspayment_linkis{code, title}and itsinvoiceis{code, number}:$charge->paymentLinkand$charge->invoicehold the code, and$charge->get('payment_link')has the rest. - An invoice has no
overdueordocumentUrl; both arenull. charge.refundedcarries no refund. Read its amount, txid and reason from the event, with$velirapay->events->retrieve($event->eventId)->details, which holdsamount, andtxidandreasonwhen you gave them.
Fetch the object when you need what a delivery leaves out. The charges that events carry, from GET /v1/events, have no timeline either.
Errors
A failed request throws an exception carrying the API's message:
| Exception | When |
|---|---|
AuthenticationException |
The API key is missing, wrong or revoked (401). |
NotFoundException |
The object does not exist, or belongs to the other mode (404). |
ConflictException |
The object cannot make that change (409), such as canceling a charge once a transfer to it has been seen or it is no longer pending, or sending or voiding an invoice that is no longer open. Also when the idempotency key was used for a different request, or is held by a request still being processed; that one carries a Retry-After and is retried for you. |
ValidationException |
The request has invalid fields (422). errors() lists them, such as coupon for a coupon that cannot be used. In test mode, an invoice emailed to someone outside your account is refused this way, on customer_email. |
RateLimitException |
Too many requests (429), including an invoice emailed again within 10 minutes, more than 100 emails to customers an hour, and more than 300 charges created in 30 minutes. retryAfter() says how many seconds to wait. |
ServerException |
VeliraPay had a problem (5xx), such as no exchange rate being available to create a charge with (503). |
InvalidRequestException |
Any other 4xx, such as an Idempotency-Key longer than 255 characters (400). |
ConnectionException |
The API could not be reached. |
They live in VeliraPay\Exceptions. All of them implement VeliraPayException, and all but ConnectionException extend ApiException, whose retryAfter() returns the response's Retry-After in seconds, or null when it had none.
use VeliraPay\Exceptions\ApiException; use VeliraPay\Exceptions\ValidationException; try { $charge = $velirapay->charges->create(['amount' => '0', 'currency' => 'EUR', 'asset' => 'BTC']); } catch (ValidationException $exception) { $exception->errors(); // ['amount' => ['The amount field must be at least 0.01.']] $exception->firstError('amount'); } catch (ApiException $exception) { $exception->status; // the HTTP status $exception->getMessage(); $exception->body; // the decoded response }
Retries and idempotency
Requests that fail with a network error, a rate limit (429) or an outage (502, 503, 504) are tried again up to twice, waiting a little longer each time, or as long as the API's Retry-After header asks. A 500 is retried for reads only, since a write may have been carried out. A 409 is retried only when it carries a Retry-After: that is the API still working on an earlier request with the same idempotency key, and the retry gets that request's response once it is done.
A Retry-After longer than maxRetryAfter (10 seconds unless configured otherwise) is not waited out: the exception is thrown straight away, and its retryAfter() says how long to wait.
While retries are on, each write carries an Idempotency-Key header, so the API carries out a retried request once and answers the retry with the first response. The library makes up a key per call; pass your own, such as an order number, to make a call safe to repeat across processes:
$charge = $velirapay->charges->create($params, idempotencyKey: 'order-1042'); $velirapay->lastResponse()?->wasReplayed(); // true when the API answered from an earlier request
What the API does with a key:
- Only a successful (2xx) response is stored, and it is replayed for 24 hours to any request repeating the key. A request that failed stored nothing, so repeating it runs it again.
- A key is at most 255 characters; a longer one is refused with a 400.
- A key covers one account, one mode, one HTTP method and one path. Every API key of that account and mode shares it, so two servers using different API keys still create one charge for one idempotency key.
- Reusing a key with a different body or query string throws a
ConflictException. - A request holds its key for up to 60 seconds while it is being processed. Another one with the same key gets a 409 with
Retry-After: 1in that time, which the library waits out and retries.
Test mode
Keys starting with vp_test_ work in test mode: charges are paid with test coins on each chain's test network, and nothing touches real funds. Test and live objects are fully separate: a live key cannot see test charges, and each mode has its own webhooks. Invoices are only emailed to members of your account.
$velirapay->isTestMode(); // read from the key's prefix
Every key starts with vp_live_ or vp_test_, and the client throws an InvalidArgumentException for any other, which the API would refuse.
Configuration
$velirapay = new VeliraPayClient( apiKey: getenv('VELIRAPAY_API_KEY'), maxRetries: 2, // 0 turns retries off maxRetryAfter: 10, // the longest Retry-After, in seconds, waited out before trying again timeout: 30.0, // seconds, for the Guzzle client the library creates appInfo: 'MyShop/2.1', // added to the User-Agent );
To use another HTTP client, or configure its proxy and timeouts yourself, pass any PSR-18 client along with PSR-17 factories if they cannot be discovered:
$velirapay = new VeliraPayClient( apiKey: getenv('VELIRAPAY_API_KEY'), httpClient: new Symfony\Component\HttpClient\Psr18Client(), );
For an endpoint the library has no method for yet, send the request yourself:
$response = $velirapay->request('GET', '/v1/charges', ['per_page' => 5]); $response->json();
Testing your integration
Sign fixtures with Webhook::signatureHeader() to test your webhook handler:
$payload = json_encode([ 'id' => 'a5f4b1c2-…', 'event_id' => null, 'event' => 'charge.paid', 'mode' => 'test', 'created_at' => date(DATE_ATOM), 'data' => ['charge' => ['code' => 'k3v9x2m7q8wz', 'status' => 'paid', 'metadata' => ['order_id' => '1042']]], ]); $this->call('POST', '/webhooks/velirapay', server: [ 'HTTP_X_VELIRAPAY_SIGNATURE' => Webhook::signatureHeader($payload, 'whsec_test'), ], content: $payload);
To test code that calls the API, pass a mock PSR-18 client to the VeliraPayClient, or use a test-mode key.
Laravel
In a Laravel application, install velirapay/velirapay-laravel instead: it configures the client from your .env, adds a facade, and turns webhooks into Laravel events.
Development
composer install composer test # PHPUnit composer types # PHPStan composer lint # Pint
License
MIT. See LICENSE.