Start by checking composer.json before you write any code. This package was last released in April 2016 and was built against the Omnipay 2.x generation (omnipay/common: ~2.0), not the PSR-7 based 3.x line. That single fact determines your whole integration strategy: on a modern Laravel app you will either pin the older stack or port the driver.
composer require omnipay/bitpay:^1.0
# verify what it pulled in
composer show omnipay/common omnipay/bitpay
Then read four files in this order — they define the entire contract:
src/Gateway.php — getName(), defaultParameters, and which parameters (apiKey, testMode, endpoint) are configurable.src/Message/PurchaseRequest.php — how Omnipay params map to BitPay's invoice payload (price, currency, posData, notificationURL, redirectURL, transactionSpeed).src/Message/PurchaseResponse.php — what getRedirectUrl()/getTransactionReference() return (BitPay returns an invoice id and a hosted url).src/Message/CompletePurchaseRequest.php — whether it fetches the invoice from BitPay or merely parses the POST body. This is the security-critical decision.The first use case to get working end-to-end is a single hosted-invoice checkout: create an invoice, redirect the customer to BitPay, receive the webhook, and mark the order paid.
use Omnipay\Omnipay;
$gateway = Omnipay::create('BitPay');
$gateway->initialize([
'apiKey' => config('services.bitpay.api_key'),
'testMode' => app()->environment('local'),
]);
$response = $gateway->purchase([
'amount' => '49.99', // fiat amount, decimal string
'currency' => 'USD',
'description' => 'Order #1042',
'transactionId' => '1042', // your reference
'returnUrl' => route('bitpay.return'),
'notifyUrl' => route('bitpay.webhook'),
'posData' => json_encode(['order_id' => 1042]),
])->send();
if (! $response->isRedirect()) {
throw new \RuntimeException($response->getMessage());
}
$order->update(['bitpay_invoice_id' => $response->getTransactionReference()]);
return $response->redirect(); // or redirect()->away($response->getRedirectUrl())
Bind the gateway once. Don't call Omnipay::create scattered through controllers. Register a singleton or a small factory in a service provider so API keys, testMode, and the HTTP client are configured in one place and swappable in tests.
Persist the invoice before redirecting. The redirect URL does not reliably carry your order id back, and BitPay's return page can be closed. Store bitpay_invoice_id, amount, currency, and status on the order before the redirect, then use posData as a secondary correlation key. Treat the invoice id as the primary key for every subsequent operation.
Split the return and webhook routes. The browser return is a UX event; the webhook is the source of truth. Both should funnel into the same verifier:
public function webhook(Request $request)
{
$invoiceId = $request->input('id'); // BitPay's invoice id
$order = Order::where('bitpay_invoice_id', $invoiceId)->firstOrFail();
// Always re-fetch from BitPay rather than trusting the POST body.
$response = $this->gateway->completePurchase([
'transactionReference' => $invoiceId,
'amount' => $order->total,
'currency' => $order->currency,
])->send();
if ($response->isSuccessful() && $order->status !== 'paid') {
$order->markPaid($response->getTransactionReference());
}
return response('ok', 200);
}
Map BitPay statuses explicitly. Expect new, paid, confirmed, complete, expired, invalid. paid means seen in the mempool; confirmed/complete means it met the confirmation threshold. Decide per-product whether to fulfil on paid (digital) or complete (physical), and store the raw status so you can change your mind later.
Reconcile stuck invoices on a schedule. Users close tabs, webhooks get lost. A queued job that polls any order in new/paid older than ~30 minutes and re-fetches the invoice will save you a support queue. Invoice expiry defaults to roughly 15 minutes on BitPay, after which the quoted BTC amount is void.
Wrap it behind your own interface. The whole point of Omnipay is that purchase()/completePurchase() look identical across gateways. Type-hint a PaymentGateway contract of your own so a future migration to BitPay's API v2 (or to the official bitpay/bitpay-php SDK) is a one-class change rather than a search-and-replace.
pos/merchant tokens). BitPay has been retiring legacy merchant endpoints for years; confirm the endpoint your driver targets still answers in production before building a checkout on it. Test against the sandbox (testMode → test.bitpay.com) with testnet coins first.AbstractGateway/message API. If you install omnipay/common: ^3.0 alongside this package you will get class-not-found or signature errors. Pin accordingly, or fork the driver and port it.fullNotifications may deliver several events per invoice. Use the invoice id as a unique constraint or an atomic where('status', '!=', 'paid') guard; return 200 quickly and do work in a job.amount/currency describe the price, not the BTC received. Don't pass a BTC quantity with currency => 'USD'. Omnipay will happily let you create a self-consistent-looking invoice that BitPay rejects.exceptionStatus for underpaid/late invoices; surface that field and treat it as a manual-review state, not an automatic failure.refund() in your abstraction just because Omnipay's base classes mention it.notifyUrl must be public HTTPS. Localhost and *.test domains silently receive nothing, which makes "the webhook never fires" the most common false bug report. Use a tunnel for local development.AbstractGateway to override the endpoint or add parameters (transactionSpeed, fullNotifications, physical), and extend CompletePurchaseRequest if you need richer verification — for example, comparing btcPrice against a tolerated slippage window, or writing a small API-v2 client alongside the driver and using it for verification only.How can I help you explore Laravel packages today?