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 v1 API (the coins on offer, charges, invoices, payment links, coupons, payouts, events, webhook endpoints and your account) and verifies the webhooks VeliraPay sends you.
- Requirements
- Installation
- Getting started
- Which one to use
- Supported coins
- Charges
- Invoices
- Payment links
- Coupons
- Payouts
- Events
- Webhook endpoints
- 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'], 'success_url' => 'https://example.com/orders/1042', ]); header('Location: '.$charge->checkoutUrl);
That is the way to charge a customer: create a charge with what you already know, and send them to its checkout page. They pay there, and VeliraPay 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.
Which one to use
| When | Use | |
|---|---|---|
| You are asking one customer for one payment | A charge | The default. It has one checkout page for that one payment, with the rate locked for 30 minutes. |
| The customer should choose the coin, or pays later | An invoice | It needs their name and email, has no timer, can be itemised and reminded, and lets them pick the coin on its page. |
| Many customers pay through the same public page | A payment link, created once | A product sold again and again, a tip jar, a link on your site. |
Do not create a new payment link for each customer or each order.
A charge needs its coin up front, because its exchange rate, amount and deposit address depend on it. Ask the customer, on your own page, and offer them the coins from $velirapay->assets->list().
Supported coins
assets->list() gives the coins that can be paid in the key's mode, whether your account takes each, and the lowest price a charge in each can have.
foreach ($velirapay->assets->list(['currency' => 'EUR']) as $asset) { $asset->id; // "USDT": what to pass as asset, and $asset->asset() is the Asset case $asset->name; // "Tether USD" $asset->network; // "Ethereum (ERC-20)", the network the customer has to send it on $asset->accepted; // whether your account takes it, which needs a payout address for that network $asset->minimumAmount; // "10", in the coin, or null when it has no minimum of its own $asset->minimumPrice; // "8.71", the lowest price in EUR right now $asset->exchangeRate; // "0.8709" $asset->confirmationWait; // "about a minute" }
USDT and USDC on Ethereum start at 10 of the token, because paying one out costs enough in gas that a smaller payment would leave you with little. A minimum is in the coin, so minimumPrice moves a little with the market: leave some room. Below it, charges->create() throws a ValidationException on asset.
Without currency, prices are in your account's default currency. The list is not paged.
Charges
A charge is how you ask a customer for a 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, with whatever you already know. $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'], 'customer' => ['email' => 'ada@example.com', 'name' => 'Ada Lovelace'], 'custom_fields' => [['label' => 'Company', 'value' => 'Analytical Engines']], // shown with the charge in the dashboard 'success_url' => 'https://example.com/orders/1042', // a button back once they have paid 'cancel_url' => 'https://example.com/cart', // a link back from the checkout 'success_message' => 'Thank you, your licence is on its way.', // on the page and in the receipt 'send_email' => true, // email them the payment page straight away ]); // 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]); $hers = $velirapay->charges->list(['search' => 'ada@example.com', 'created_from' => '2026-10-01', 'asset' => Asset::BTC]); // Email the customer the payment page, if you did not pass send_email. $velirapay->charges->send('k3v9x2m7q8wz'); // Only while the charge is pending and no transfer to it has been seen. $velirapay->charges->cancel('k3v9x2m7q8wz'); // Settle an underpaid charge, or a late payment on an expired or canceled one, for what arrived. $velirapay->charges->accept('k3v9x2m7q8wz'); // Record a refund you sent from your own wallet. $velirapay->charges->recordRefund('k3v9x2m7q8wz', [ 'amount' => '0.0005', 'txid' => '7d1c5e9a…', 'reason' => 'Returned item', ]);
send() needs a customer email and only works while the charge is pending, or it throws a ConflictException. Creating a charge with send_email but no customer email throws a ValidationException on customer_email and creates nothing. A charge can be emailed once every 10 minutes, and the emails count towards the limits under Invoices. In test mode, charge emails can only go to members of your account.
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 one reusable public checkout page, for a fixed price or one the customer chooses, opened by many customers who each pick their own coin. Create it once and share it. To charge one customer, create a charge instead.
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); $existing = $velirapay->paymentLinks->list(['status' => 'active', 'search' => 'Pro plan']);
Invoices
An invoice is a bill with a hosted page, where the customer pays in the coin of their choice. Use it for a bill to pay later, or when the customer should choose the coin.
$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); $overdue = $velirapay->invoices->list(['overdue' => true]); $hers = $velirapay->invoices->list(['search' => 'grace@example.com']);
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, charges emailed to their customers 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 $found = $velirapay->coupons->list(['search' => 'spring']);
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.
Payouts
Every paid charge has one payout: what came in, what was kept, what you are owed, what actually landed and the transaction that carried it. These only read: payouts go out on their own, and nothing here can raise, redirect or reverse one.
use VeliraPay\Enums\Asset; use VeliraPay\Enums\PayoutStatus; $payout = $velirapay->payouts->retrieve('k3v9x2m7q8wz'); // by the charge's id; 404 until it is paid $payout->status; // "pending", "sending", "sent", "failed" or "manual" $payout->isSent(); // confirmed: the money is in your wallet $payout->grossAmount; // what the customer paid $payout->feeAmount; // what was kept $payout->netAmount; // what you are owed, before the chain's own fee $payout->networkFee; // what the chain charged $payout->sentAmount; // what therefore reached you $payout->payoutTxid; // the transaction, and $payout->explorerUrl its page on a block explorer $payout->lastError; // why the last try did not work, or why it is held $payout->nextAttemptAt; $failed = $velirapay->payouts->list(['status' => PayoutStatus::Failed]); $tether = $velirapay->payouts->list(['asset' => Asset::USDT, 'search' => '0x9a4f']); // a charge id or a transaction hash foreach ($velirapay->payouts->summary() as $coin) { $coin->asset; // one row per coin you have been paid in $coin->paidOutAmount; // what has reached you $coin->inFlightAmount; // what is on its way $coin->owedAmount; // what has not left yet $coin->feeAmount; $coin->waitingForAddress; // how many payouts wait for you to give an address }
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
Webhook endpoints
List them to see from your code why deliveries are not arriving:
foreach ($velirapay->webhookEndpoints->list() as $endpoint) { $endpoint->url; $endpoint->events; // the event types it takes $endpoint->active; $endpoint->verified; // it receives nothing until it has answered the challenge $endpoint->isReceiving(); // both of those $endpoint->deliveriesCount; // of the last 30 days $endpoint->failedDeliveriesCount; } foreach ($velirapay->webhookEndpoints->deliveries('12', ['status' => 'failed']) as $delivery) { $delivery->event; // "charge.paid" $delivery->responseStatus; // what the endpoint last answered $delivery->error; }
A key with the sensitive webhook_endpoints:write permission ticked can also add and change them. A new endpoint is sent its verification challenge at once, and the answer that adds it, or rotates its secret, is the only one that carries the signing secret:
$endpoint = $velirapay->webhookEndpoints->create([ 'url' => 'https://example.com/webhooks/velirapay', 'events' => ['charge.paid', 'invoice.paid'], ]); $endpoint->secret; // "whsec_…", store it now: it is never returned again $endpoint->verificationError; // null once the endpoint answered the challenge $velirapay->webhookEndpoints->update('12', ['active' => false]); $velirapay->webhookEndpoints->verify('12'); // send the challenge again $velirapay->webhookEndpoints->test('12'); // a sample charge.paid, marked test $velirapay->webhookEndpoints->rotateSecret('12')->secret; // the old one keeps signing for a day $velirapay->webhookEndpoints->retryDelivery('12', $delivery->id); $velirapay->webhookEndpoints->delete('12');
Your account
$account = $velirapay->account->retrieve(); $account->displayName; $account->supportEmail; $account->supportPhone; // "+442071234567", shown to customers next to the support email $account->logoDarkUrl; // the logo for a dark background, or null $account->termsUrl; $account->refundPolicyUrl; $account->acceptedAssets; // ['BTC', 'ETH', …], the coins it takes right now, in the key's mode $account->mode; // "live" or "test" $account->checkout; // appearance, coin_order, default_coin, success_url, cancel_url, success_message, hide_powered_by $account->invoices; // number_prefix, footer, show_business_details $account->checkoutLocale; $account->invoiceReminders; $account->apiPayoutAddressChanges; // whether the owner allows remote payout address changes $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.
The plan is on the account too: $account->plan ("free" or "pro"), $account->feePercent and $account->proEndsAt, with $account->isPro().
A key with the sensitive account:write or payment_settings:write permission can change the account, in the shape it is read in, with null to clear a field:
$velirapay->account->update(['display_name' => 'Acme', 'checkout' => ['appearance' => 'dark']]); $velirapay->account->updatePaymentSettings(['underpayment_tolerance' => '2', 'invoice_reminders' => true]);
Overview and activity
$overview = $velirapay->overview->retrieve(); // the dashboard's last 30 days; needs overview:read $overview->volume; // in $overview->currency $overview->paidCharges; $overview->get('attention'); // what needs a person now foreach ($velirapay->activity->list(['action' => 'sensitive']) as $entry) { // needs activity:read $entry->action; // "api_key.created" $entry->actor; // a member's name, or 'API key "Shop server"' $entry->ipAddress; }
Payout addresses
foreach ($velirapay->payoutAddresses->list() as $address) { // needs payout_addresses:read $address->network; // "bitcoin", "evm", … $address->address; // null while there is none: payouts in its coins wait for one } $velirapay->payoutAddresses->update('bitcoin', ['address' => 'bc1q…']);
With one entry per network, this list() returns a plain array rather than a page. Changing one needs the sensitive payout_addresses:write permission and an owner switching on "Let API keys and AI apps change payout addresses" under Payment settings. Owners and admins are emailed about every change, and payouts to a new address wait 48 hours once the account has been paid out.
API keys, members and Pro
$apiKey = $velirapay->apiKeys->create(['name' => 'Shop server', 'permissions' => ['charges:read', 'charges:write']]); $apiKey->key; // "vp_live_…", shown this once $velirapay->invitations->create(['email' => 'grace@example.com', 'role' => 'admin']); $velirapay->members->update('3', ['role' => 'member']); $payment = $velirapay->billing->startProPayment(['asset' => 'BTC']); // with a live key $payment->checkoutUrl; // where to pay a month of Pro
api_keys:read, api_keys:write, members:read and members:write are sensitive. A key can make or change no key with a permission or an IP address it does not have itself, nor touch a key that reaches further. Only a key an owner made changes a role or removes a member, and owners are never changed this way. billing:write comes with full access.
Lists and pagination
Paged list() methods return one page, newest first (members are oldest first). Assets, payout addresses and payout summaries return plain arrays. Pass page and per_page (up to 100, 25 by default) to move through a paged list:
$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; } // Sent when the endpoint is added or moved: it receives nothing else until it answers. if ($event->isVerification()) { header('Content-Type: application/json'); echo $event->challengeResponse(); 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->isVerification()) { return response()->json(['challenge' => $event->challenge()]); } 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 the verification challenge. When an endpoint is added, or its URL changes, VeliraPay sends it a signed
webhook.verificationdelivery and sends it nothing else until it answers with HTTP 200 and the challenge, as$event->challengeResponse()gives it. Endpoint URLs must usehttps://. - A second signature for a day after a rotation. For a day after you rotate the signing secret, the
X-VeliraPay-Signatureheader carries av1made with the old secret too, so a receiver that still has the old one keeps working.constructEvent()accepts a delivery whicheverv1matches. - 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 more than 5 minutes from the current time is rejected, which stops an intercepted delivery from being replayed. Change the limit with the non-negative
toleranceargument;0explicitly disables the age check.
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.
payout.sent arrives when your share of a paid charge has been broadcast, and payout.confirmed when the chain has confirmed it. Both carry the charge as $event->charge and the payout as $event->payout, the same object $velirapay->payouts->retrieve() returns; for every other event it is null. Book the money on payout.confirmed: a broadcast payout can still be dropped or fail and go out again, so payout.sent can arrive more than once for a charge. networkFee on payout.sent can be the most the chain may charge, where payout.confirmed has what it took, and a USDT or USDC payout left unmined bids again under a new payoutTxid, so the one that counts is the one in payout.confirmed.
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). |
PermissionException |
The API key may not do this (403): a restricted key without the permission the endpoint needs, such as charges:write, or a key used from an IP address it does not allow. The message names which. An owner or admin can change the key's permissions and addresses in the dashboard; the key itself stays the same. |
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, accepting one that is neither underpaid nor late, paying a live charge by simulation, 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:
- Successful (2xx) responses are replayed for 24 hours to requests repeating the key. A request that failed stores nothing, so repeating it runs it again.
- An answer that carries a new API key or webhook signing secret is never stored. Creating a key or endpoint, or rotating an endpoint's secret, succeeds once; repeating its idempotency key throws a
ConflictExceptionsaying the request already succeeded. If the first answer was lost, list the created objects and recover through revoking the key or rotating the endpoint's secret, instead of blindly repeating with a new idempotency key. - 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 // Pay a test charge without any coins: it becomes paid, and the webhooks and emails go out as for a real payment. $velirapay->charges->simulate('k3v9x2m7q8wz'); $velirapay->charges->simulate('k3v9x2m7q8wz', ['amount' => '0.001']); // less than is due, to try an underpayment
BNB Chain has no test mode, so its coins can only be used with a live key.
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.